删除库存和旧回调管理
This commit is contained in:
@@ -89,7 +89,7 @@ npm test
|
|||||||
运行日志默认保存在:
|
运行日志默认保存在:
|
||||||
|
|
||||||
- `data/logs/app-YYYY-MM-DD.log`
|
- `data/logs/app-YYYY-MM-DD.log`
|
||||||
- `data/logs/webhook-YYYY-MM-DD.log`
|
- `data/logs/integration-YYYY-MM-DD.log`
|
||||||
|
|
||||||
日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。
|
日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。
|
||||||
|
|
||||||
|
|||||||
@@ -135,42 +135,6 @@ CREATE TABLE IF NOT EXISTS order_items (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
|
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code);
|
CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS inventory_items (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
batch_no TEXT NOT NULL DEFAULT '',
|
|
||||||
sku_code TEXT NOT NULL,
|
|
||||||
credential_type TEXT NOT NULL,
|
|
||||||
display_value TEXT NOT NULL DEFAULT '',
|
|
||||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
source_type TEXT NOT NULL DEFAULT 'static_import',
|
|
||||||
status TEXT NOT NULL DEFAULT 'available',
|
|
||||||
invalid_reason TEXT NOT NULL DEFAULT '',
|
|
||||||
inventory_group_code TEXT NOT NULL DEFAULT '',
|
|
||||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
consumed_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_items_unique_payload
|
|
||||||
ON inventory_items(sku_code, credential_type, md5(payload_json::text));
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_inventory_items_pool
|
|
||||||
ON inventory_items(sku_code, credential_type, status, id);
|
|
||||||
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);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fulfillment_tasks (
|
CREATE TABLE IF NOT EXISTS fulfillment_tasks (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
@@ -185,7 +149,6 @@ CREATE TABLE IF NOT EXISTS fulfillment_tasks (
|
|||||||
profile_id BIGINT NOT NULL REFERENCES fulfillment_profiles(id) ON DELETE RESTRICT,
|
profile_id BIGINT NOT NULL REFERENCES fulfillment_profiles(id) ON DELETE RESTRICT,
|
||||||
executor_key TEXT NOT NULL,
|
executor_key TEXT NOT NULL,
|
||||||
task_status TEXT NOT NULL DEFAULT 'pending_payment',
|
task_status TEXT NOT NULL DEFAULT 'pending_payment',
|
||||||
inventory_status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
delivery_status TEXT NOT NULL DEFAULT 'pending',
|
delivery_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
result_code TEXT NOT NULL DEFAULT '',
|
result_code TEXT NOT NULL DEFAULT '',
|
||||||
result_message TEXT NOT NULL DEFAULT '',
|
result_message TEXT NOT NULL DEFAULT '',
|
||||||
@@ -224,24 +187,6 @@ CREATE TABLE IF NOT EXISTS claim_tokens (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status, expired_at);
|
CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status, expired_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS task_inventory_bindings (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
|
||||||
inventory_item_id BIGINT NOT NULL REFERENCES inventory_items(id) ON DELETE RESTRICT,
|
|
||||||
role_key TEXT NOT NULL,
|
|
||||||
quantity INTEGER NOT NULL DEFAULT 1,
|
|
||||||
binding_status TEXT NOT NULL DEFAULT 'reserved',
|
|
||||||
consumed_at TIMESTAMPTZ,
|
|
||||||
released_at TIMESTAMPTZ,
|
|
||||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL,
|
|
||||||
UNIQUE(task_id, role_key, inventory_item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_task_inventory_bindings_task ON task_inventory_bindings(task_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_task_inventory_bindings_inventory ON task_inventory_bindings(inventory_item_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS task_events (
|
CREATE TABLE IF NOT EXISTS task_events (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
||||||
@@ -268,24 +213,3 @@ CREATE TABLE IF NOT EXISTS task_runtime_contexts (
|
|||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS webhook_events (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
provider TEXT NOT NULL DEFAULT '',
|
|
||||||
platform TEXT NOT NULL DEFAULT '',
|
|
||||||
shop_id TEXT NOT NULL DEFAULT '',
|
|
||||||
shop_name TEXT NOT NULL DEFAULT '',
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
event_key TEXT NOT NULL,
|
|
||||||
signature_valid BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
headers_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
query_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
body_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
processed BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
process_error TEXT NOT NULL DEFAULT '',
|
|
||||||
related_order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_lookup ON webhook_events(provider, platform, shop_id, event_key);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed, created_at DESC);
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { query, withTransaction } from '../db/client.js'
|
import { query } from '../db/client.js'
|
||||||
|
|
||||||
type AdminUserRow = {
|
type AdminUserRow = {
|
||||||
id: number
|
id: number
|
||||||
@@ -8,7 +8,6 @@ type AdminUserRow = {
|
|||||||
status: string
|
status: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
inventory_group_codes: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUserCreateInput = {
|
type AdminUserCreateInput = {
|
||||||
@@ -39,15 +38,8 @@ type AdminUserListResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ADMIN_USER_SELECT = `
|
const ADMIN_USER_SELECT = `
|
||||||
SELECT
|
SELECT au.*
|
||||||
au.*,
|
|
||||||
COALESCE(bindings.inventory_group_codes, ARRAY[]::text[]) AS inventory_group_codes
|
|
||||||
FROM admin_users au
|
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: number | string): Promise<AdminUserRow | null> {
|
export async function getAdminUserById(userId: number | string): Promise<AdminUserRow | null> {
|
||||||
@@ -183,39 +175,3 @@ export async function countActiveAdminUsers(): Promise<number> {
|
|||||||
)
|
)
|
||||||
return Number(result.rows[0]?.total || 0)
|
return Number(result.rows[0]?.total || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function replaceAdminUserInventoryGroupBindings(
|
|
||||||
userId: number | string,
|
|
||||||
inventoryGroupCodes: unknown[] = [],
|
|
||||||
timestamp: string,
|
|
||||||
): Promise<AdminUserRow | null> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,497 +0,0 @@
|
|||||||
import { query, withTransaction } from '../db/client.js'
|
|
||||||
import type {
|
|
||||||
InventoryCreateItemInput,
|
|
||||||
InventoryListQueryInput,
|
|
||||||
InventorySkuSuggestionQueryInput,
|
|
||||||
} from '../types/repository-inputs.js'
|
|
||||||
import type {
|
|
||||||
InventoryItemRow,
|
|
||||||
InventoryListQueryResult,
|
|
||||||
InventorySkuSuggestionRow,
|
|
||||||
} from '../types/repository-rows.js'
|
|
||||||
|
|
||||||
type InventoryGroupCodesInput = string | string[] | null | undefined
|
|
||||||
|
|
||||||
type InventoryGroupListInput = {
|
|
||||||
keyword?: string
|
|
||||||
limit?: number | string
|
|
||||||
}
|
|
||||||
|
|
||||||
const INVENTORY_ITEM_SELECT = `
|
|
||||||
SELECT
|
|
||||||
ii.id,
|
|
||||||
ii.sku_code,
|
|
||||||
ii.batch_no,
|
|
||||||
ii.credential_type,
|
|
||||||
ii.inventory_group_code,
|
|
||||||
ii.display_value,
|
|
||||||
ii.status,
|
|
||||||
ii.invalid_reason,
|
|
||||||
ii.consumed_at AS delivered_at,
|
|
||||||
ii.created_at,
|
|
||||||
ii.updated_at,
|
|
||||||
tib.task_id AS reserved_by_task_id,
|
|
||||||
ft.task_no AS reserved_by_task_no,
|
|
||||||
ft.platform_order_id
|
|
||||||
FROM inventory_items ii
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT *
|
|
||||||
FROM task_inventory_bindings
|
|
||||||
WHERE inventory_item_id = ii.id AND binding_status IN ('reserved', 'consumed')
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) tib ON TRUE
|
|
||||||
LEFT JOIN fulfillment_tasks ft ON ft.id = tib.task_id
|
|
||||||
`
|
|
||||||
|
|
||||||
export async function findFirstAvailableInventoryItemBySkuCode(
|
|
||||||
skuCode: string,
|
|
||||||
credentialType = 'claim_code',
|
|
||||||
inventoryGroupCodes: InventoryGroupCodesInput = null,
|
|
||||||
): Promise<InventoryItemRow | null> {
|
|
||||||
const normalizedInventoryGroupCodes = normalizeInventoryGroupCodes(inventoryGroupCodes)
|
|
||||||
if (normalizedInventoryGroupCodes && normalizedInventoryGroupCodes.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const params: unknown[] = [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<InventoryItemRow>(
|
|
||||||
`${INVENTORY_ITEM_SELECT}
|
|
||||||
WHERE ${filters.join(' AND ')}
|
|
||||||
ORDER BY ii.id ASC
|
|
||||||
LIMIT 1`,
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assignReservedInventoryItem(
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
taskId: number | string,
|
|
||||||
updatedAt: string,
|
|
||||||
roleKey = 'primary_code',
|
|
||||||
): Promise<InventoryItemRow | null> {
|
|
||||||
return withTransaction(async (client) => {
|
|
||||||
const inventoryResult = await client.query(
|
|
||||||
`
|
|
||||||
UPDATE inventory_items
|
|
||||||
SET status = 'reserved', updated_at = $1
|
|
||||||
WHERE id = $2 AND status = 'available'
|
|
||||||
RETURNING id
|
|
||||||
`,
|
|
||||||
[updatedAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!inventoryResult.rows[0]) {
|
|
||||||
return getInventoryItemById(inventoryItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
INSERT INTO task_inventory_bindings (
|
|
||||||
task_id,
|
|
||||||
inventory_item_id,
|
|
||||||
role_key,
|
|
||||||
quantity,
|
|
||||||
binding_status,
|
|
||||||
metadata_json,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
) VALUES ($1, $2, $3, 1, 'reserved', '{}'::jsonb, $4, $4)
|
|
||||||
ON CONFLICT (task_id, role_key, inventory_item_id) DO UPDATE
|
|
||||||
SET binding_status = 'reserved', updated_at = EXCLUDED.updated_at, released_at = NULL
|
|
||||||
`,
|
|
||||||
[Number(taskId), Number(inventoryItemId), roleKey, updatedAt],
|
|
||||||
)
|
|
||||||
|
|
||||||
return getInventoryItemById(inventoryItemId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getInventoryItemById(inventoryItemId: number | string): Promise<InventoryItemRow | null> {
|
|
||||||
const result = await query<InventoryItemRow>(
|
|
||||||
`${INVENTORY_ITEM_SELECT}
|
|
||||||
WHERE ii.id = $1
|
|
||||||
LIMIT 1`,
|
|
||||||
[Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markInventoryItemDelivered(
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
deliveredAt: string,
|
|
||||||
): Promise<InventoryItemRow | null> {
|
|
||||||
return withTransaction(async (client) => {
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE inventory_items
|
|
||||||
SET status = 'consumed', consumed_at = $1, updated_at = $1
|
|
||||||
WHERE id = $2
|
|
||||||
`,
|
|
||||||
[deliveredAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE task_inventory_bindings
|
|
||||||
SET binding_status = 'consumed', consumed_at = $1, updated_at = $1
|
|
||||||
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
|
|
||||||
`,
|
|
||||||
[deliveredAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return getInventoryItemById(inventoryItemId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markInventoryItemConsumed(
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
reason: string,
|
|
||||||
consumedAt: string,
|
|
||||||
): Promise<InventoryItemRow | null> {
|
|
||||||
return withTransaction(async (client) => {
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE inventory_items
|
|
||||||
SET
|
|
||||||
status = 'consumed',
|
|
||||||
invalid_reason = $1,
|
|
||||||
consumed_at = $2,
|
|
||||||
updated_at = $2
|
|
||||||
WHERE id = $3
|
|
||||||
`,
|
|
||||||
[String(reason || '').trim(), consumedAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE task_inventory_bindings
|
|
||||||
SET binding_status = 'consumed', consumed_at = $1, updated_at = $1
|
|
||||||
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
|
|
||||||
`,
|
|
||||||
[consumedAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return getInventoryItemById(inventoryItemId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listInventoryItems({
|
|
||||||
page = 1,
|
|
||||||
pageSize = 20,
|
|
||||||
skuCode = '',
|
|
||||||
credentialType = '',
|
|
||||||
status = '',
|
|
||||||
batchNo = '',
|
|
||||||
inventoryGroupCode = '',
|
|
||||||
allowedInventoryGroupCodes = null,
|
|
||||||
}: InventoryListQueryInput = {}): Promise<InventoryListQueryResult> {
|
|
||||||
const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes)
|
|
||||||
if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) {
|
|
||||||
return {
|
|
||||||
items: [],
|
|
||||||
total: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = (page - 1) * pageSize
|
|
||||||
const filters: string[] = []
|
|
||||||
const params: unknown[] = []
|
|
||||||
|
|
||||||
if (skuCode) {
|
|
||||||
params.push(skuCode)
|
|
||||||
filters.push(`ii.sku_code = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
params.push(status === 'delivered' ? 'consumed' : status)
|
|
||||||
filters.push(`ii.status = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (credentialType) {
|
|
||||||
params.push(credentialType)
|
|
||||||
filters.push(`ii.credential_type = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batchNo) {
|
|
||||||
params.push(`%${batchNo}%`)
|
|
||||||
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<{ [column: string]: unknown, total: number }>(
|
|
||||||
`SELECT COUNT(*)::int AS total FROM inventory_items ii ${whereClause}`,
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
params.push(pageSize)
|
|
||||||
params.push(offset)
|
|
||||||
const itemsResult = await query<InventoryItemRow>(
|
|
||||||
`${INVENTORY_ITEM_SELECT}
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY ii.id DESC
|
|
||||||
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: itemsResult.rows,
|
|
||||||
total: Number(totalResult.rows[0]?.total || 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listInventorySkuSuggestions({
|
|
||||||
credentialType = '',
|
|
||||||
keyword = '',
|
|
||||||
limit = 50,
|
|
||||||
inventoryGroupCode = '',
|
|
||||||
allowedInventoryGroupCodes = null,
|
|
||||||
}: InventorySkuSuggestionQueryInput = {}): Promise<InventorySkuSuggestionRow[]> {
|
|
||||||
const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes)
|
|
||||||
if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters: string[] = []
|
|
||||||
const params: unknown[] = []
|
|
||||||
|
|
||||||
if (credentialType) {
|
|
||||||
params.push(credentialType)
|
|
||||||
filters.push(`credential_type = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
params.push(`%${keyword}%`)
|
|
||||||
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
|
|
||||||
|
|
||||||
params.push(normalizedLimit)
|
|
||||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
|
||||||
const result = await query<InventorySkuSuggestionRow>(
|
|
||||||
`
|
|
||||||
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, inventory_group_code
|
|
||||||
ORDER BY
|
|
||||||
COUNT(*) FILTER (WHERE status = 'available') DESC,
|
|
||||||
COUNT(*) DESC,
|
|
||||||
MAX(updated_at) DESC,
|
|
||||||
sku_code ASC,
|
|
||||||
inventory_group_code ASC
|
|
||||||
LIMIT $${params.length}
|
|
||||||
`,
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createInventoryItems(rows: InventoryCreateItemInput[]): Promise<number> {
|
|
||||||
let created = 0
|
|
||||||
|
|
||||||
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<{ [column: string]: unknown, id: number }>(
|
|
||||||
`
|
|
||||||
INSERT INTO inventory_items (
|
|
||||||
batch_no,
|
|
||||||
sku_code,
|
|
||||||
credential_type,
|
|
||||||
inventory_group_code,
|
|
||||||
display_value,
|
|
||||||
payload_json,
|
|
||||||
source_type,
|
|
||||||
status,
|
|
||||||
invalid_reason,
|
|
||||||
metadata_json,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'static_import', 'available', '', '{}'::jsonb, $7, $8)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
RETURNING id
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
row.batchNo || '',
|
|
||||||
row.skuCode,
|
|
||||||
row.credentialType || 'claim_code',
|
|
||||||
inventoryGroupCode,
|
|
||||||
displayValue,
|
|
||||||
payloadJson,
|
|
||||||
row.createdAt,
|
|
||||||
row.updatedAt,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.rows[0]?.id) {
|
|
||||||
created += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listInventoryGroupCodes({
|
|
||||||
keyword = '',
|
|
||||||
limit = 100,
|
|
||||||
}: InventoryGroupListInput = {}): Promise<string[]> {
|
|
||||||
const params: unknown[] = []
|
|
||||||
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<{ [column: string]: unknown, inventory_group_code: string }>(
|
|
||||||
`
|
|
||||||
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: InventoryGroupCodesInput): string[] | null {
|
|
||||||
if (inventoryGroupCodes == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(new Set((Array.isArray(inventoryGroupCodes) ? inventoryGroupCodes : [inventoryGroupCodes])
|
|
||||||
.map((value) => String(value || '').trim())
|
|
||||||
.filter(Boolean)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function releaseReservedInventoryItem(
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
updatedAt: string,
|
|
||||||
): Promise<InventoryItemRow | null> {
|
|
||||||
return withTransaction(async (client) => {
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE task_inventory_bindings
|
|
||||||
SET binding_status = 'released', released_at = $1, updated_at = $1
|
|
||||||
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
|
|
||||||
`,
|
|
||||||
[updatedAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE inventory_items
|
|
||||||
SET status = 'available', updated_at = $1
|
|
||||||
WHERE id = $2 AND status = 'reserved'
|
|
||||||
`,
|
|
||||||
[updatedAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return getInventoryItemById(inventoryItemId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function invalidateInventoryItem(
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
invalidReason: string,
|
|
||||||
updatedAt: string,
|
|
||||||
): Promise<InventoryItemRow | null> {
|
|
||||||
const result = await query<{ [column: string]: unknown, id: number }>(
|
|
||||||
`
|
|
||||||
UPDATE inventory_items
|
|
||||||
SET status = 'invalid', invalid_reason = $1, updated_at = $2
|
|
||||||
WHERE id = $3 AND status = 'available'
|
|
||||||
RETURNING id
|
|
||||||
`,
|
|
||||||
[invalidReason, updatedAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.rows[0]) {
|
|
||||||
return getInventoryItemById(inventoryItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return getInventoryItemById(inventoryItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function invalidateReservedInventoryItem(
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
invalidReason: string,
|
|
||||||
updatedAt: string,
|
|
||||||
): Promise<InventoryItemRow | null> {
|
|
||||||
return withTransaction(async (client) => {
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE task_inventory_bindings
|
|
||||||
SET binding_status = 'released', released_at = $1, updated_at = $1
|
|
||||||
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
|
|
||||||
`,
|
|
||||||
[updatedAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE inventory_items
|
|
||||||
SET status = 'invalid', invalid_reason = $1, updated_at = $2
|
|
||||||
WHERE id = $3
|
|
||||||
`,
|
|
||||||
[String(invalidReason || '').trim(), updatedAt, Number(inventoryItemId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return getInventoryItemById(inventoryItemId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
|||||||
|
|
||||||
import { resolveOrderItemSyncPlan } from './order-item-repo.js'
|
import { resolveOrderItemSyncPlan } from './order-item-repo.js'
|
||||||
|
|
||||||
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated webhook syncs', () => {
|
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated source syncs', () => {
|
||||||
const existingItems = [
|
const existingItems = [
|
||||||
{
|
{
|
||||||
id: 15,
|
id: 15,
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import { query } from '../db/client.js'
|
|
||||||
import type {
|
|
||||||
TaskInventoryBindingRow,
|
|
||||||
TaskInventoryBindingSummaryRow,
|
|
||||||
} from '../types/repository-rows.js'
|
|
||||||
|
|
||||||
export async function listTaskInventoryBindingsByTaskId(
|
|
||||||
taskId: number | string,
|
|
||||||
): Promise<TaskInventoryBindingRow[]> {
|
|
||||||
const result = await query<TaskInventoryBindingRow>(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
tib.id,
|
|
||||||
tib.task_id,
|
|
||||||
tib.inventory_item_id,
|
|
||||||
tib.role_key,
|
|
||||||
tib.quantity,
|
|
||||||
tib.binding_status,
|
|
||||||
tib.consumed_at,
|
|
||||||
tib.released_at,
|
|
||||||
tib.metadata_json,
|
|
||||||
tib.created_at,
|
|
||||||
tib.updated_at,
|
|
||||||
ii.sku_code,
|
|
||||||
ii.batch_no,
|
|
||||||
ii.credential_type,
|
|
||||||
ii.inventory_group_code,
|
|
||||||
ii.display_value,
|
|
||||||
ii.status AS inventory_item_status,
|
|
||||||
ii.invalid_reason
|
|
||||||
FROM task_inventory_bindings tib
|
|
||||||
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
|
||||||
WHERE tib.task_id = $1
|
|
||||||
ORDER BY tib.id ASC
|
|
||||||
`,
|
|
||||||
[Number(taskId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTaskInventoryBindingById(
|
|
||||||
bindingId: number | string,
|
|
||||||
): Promise<TaskInventoryBindingRow | null> {
|
|
||||||
const result = await query<TaskInventoryBindingRow>(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
tib.id,
|
|
||||||
tib.task_id,
|
|
||||||
tib.inventory_item_id,
|
|
||||||
tib.role_key,
|
|
||||||
tib.quantity,
|
|
||||||
tib.binding_status,
|
|
||||||
tib.consumed_at,
|
|
||||||
tib.released_at,
|
|
||||||
tib.metadata_json,
|
|
||||||
tib.created_at,
|
|
||||||
tib.updated_at,
|
|
||||||
ii.sku_code,
|
|
||||||
ii.batch_no,
|
|
||||||
ii.credential_type,
|
|
||||||
ii.inventory_group_code,
|
|
||||||
ii.display_value,
|
|
||||||
ii.status AS inventory_item_status,
|
|
||||||
ii.invalid_reason
|
|
||||||
FROM task_inventory_bindings tib
|
|
||||||
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
|
||||||
WHERE tib.id = $1
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[Number(bindingId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTaskInventoryBindingSummariesByTaskIds(
|
|
||||||
taskIds: unknown[] = [],
|
|
||||||
): Promise<TaskInventoryBindingSummaryRow[]> {
|
|
||||||
const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : [])
|
|
||||||
.map((value) => Number(value))
|
|
||||||
.filter((value) => Number.isFinite(value) && value > 0)))
|
|
||||||
|
|
||||||
if (normalizedTaskIds.length === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query<TaskInventoryBindingSummaryRow>(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
tib.task_id,
|
|
||||||
COUNT(*)::int AS total_binding_count,
|
|
||||||
COUNT(*) FILTER (WHERE tib.binding_status = 'reserved')::int AS reserved_binding_count,
|
|
||||||
COUNT(*) FILTER (WHERE tib.binding_status = 'consumed')::int AS consumed_binding_count,
|
|
||||||
COUNT(*) FILTER (WHERE tib.binding_status = 'released')::int AS released_binding_count,
|
|
||||||
ARRAY_AGG(DISTINCT tib.role_key ORDER BY tib.role_key) AS role_keys
|
|
||||||
FROM task_inventory_bindings tib
|
|
||||||
WHERE tib.task_id = ANY($1::bigint[])
|
|
||||||
GROUP BY tib.task_id
|
|
||||||
`,
|
|
||||||
[normalizedTaskIds],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
@@ -42,35 +42,13 @@ const TASK_FIELDS = `
|
|||||||
ctx.partition_name,
|
ctx.partition_name,
|
||||||
ctx.screenshot_path,
|
ctx.screenshot_path,
|
||||||
ctx.artifacts_json,
|
ctx.artifacts_json,
|
||||||
ctx.state_json,
|
ctx.state_json
|
||||||
inv.inventory_item_id AS primary_inventory_item_id,
|
|
||||||
inv.display_value AS primary_inventory_display_value,
|
|
||||||
inv.credential_type AS primary_inventory_credential_type,
|
|
||||||
inv.binding_status AS primary_inventory_binding_status
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const TASK_JOINS = `
|
const TASK_JOINS = `
|
||||||
FROM fulfillment_tasks ft
|
FROM fulfillment_tasks ft
|
||||||
LEFT JOIN claim_tokens ct ON ct.task_id = ft.id
|
LEFT JOIN claim_tokens ct ON ct.task_id = ft.id
|
||||||
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
|
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT
|
|
||||||
tib.inventory_item_id,
|
|
||||||
tib.binding_status,
|
|
||||||
ii.display_value,
|
|
||||||
ii.credential_type
|
|
||||||
FROM task_inventory_bindings tib
|
|
||||||
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
|
||||||
WHERE tib.task_id = ft.id AND tib.binding_status IN ('reserved', 'consumed')
|
|
||||||
ORDER BY
|
|
||||||
CASE tib.binding_status
|
|
||||||
WHEN 'reserved' THEN 0
|
|
||||||
WHEN 'consumed' THEN 1
|
|
||||||
ELSE 2
|
|
||||||
END ASC,
|
|
||||||
tib.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) inv ON TRUE
|
|
||||||
`
|
`
|
||||||
|
|
||||||
function buildTaskSelect(extraFields = ''): string {
|
function buildTaskSelect(extraFields = ''): string {
|
||||||
@@ -106,7 +84,6 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
|||||||
profile_id,
|
profile_id,
|
||||||
executor_key,
|
executor_key,
|
||||||
task_status,
|
task_status,
|
||||||
inventory_status,
|
|
||||||
delivery_status,
|
delivery_status,
|
||||||
result_code,
|
result_code,
|
||||||
result_message,
|
result_message,
|
||||||
@@ -123,7 +100,7 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
$11, $12, $13, $14, $15, $16, $17, $18, $19,
|
$11, $12, $13, $14, $15, $16, $17, $18, $19,
|
||||||
$20, $21, $22, $23, $24::jsonb, $25, $26
|
$20, $21, $22, $23::jsonb, $24, $25
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
@@ -140,7 +117,6 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
|||||||
input.profileId,
|
input.profileId,
|
||||||
input.executorKey,
|
input.executorKey,
|
||||||
input.taskStatus || 'pending_payment',
|
input.taskStatus || 'pending_payment',
|
||||||
input.inventoryStatus || 'pending',
|
|
||||||
input.deliveryStatus || 'pending',
|
input.deliveryStatus || 'pending',
|
||||||
input.resultCode || '',
|
input.resultCode || '',
|
||||||
input.resultMessage || '',
|
input.resultMessage || '',
|
||||||
@@ -180,27 +156,25 @@ export async function updateTask(taskId: number | string, patch: TaskUpdatePatch
|
|||||||
UPDATE fulfillment_tasks
|
UPDATE fulfillment_tasks
|
||||||
SET
|
SET
|
||||||
task_status = $1,
|
task_status = $1,
|
||||||
inventory_status = $2,
|
delivery_status = $2,
|
||||||
delivery_status = $3,
|
result_code = $3,
|
||||||
result_code = $4,
|
result_message = $4,
|
||||||
result_message = $5,
|
claim_token = $5,
|
||||||
claim_token = $6,
|
claim_expires_at = $6,
|
||||||
claim_expires_at = $7,
|
automation_mode = $7,
|
||||||
automation_mode = $8,
|
requires_claim = $8,
|
||||||
requires_claim = $9,
|
user_action_status = $9,
|
||||||
user_action_status = $10,
|
attempt_count = $10,
|
||||||
attempt_count = $11,
|
last_error = $11,
|
||||||
last_error = $12,
|
context_json = $12::jsonb,
|
||||||
context_json = $13::jsonb,
|
claimed_at = $13,
|
||||||
claimed_at = $14,
|
role_confirmed_at = $14,
|
||||||
role_confirmed_at = $15,
|
redeemed_at = $15,
|
||||||
redeemed_at = $16,
|
updated_at = $16
|
||||||
updated_at = $17
|
WHERE id = $17
|
||||||
WHERE id = $18
|
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
next.task_status,
|
next.task_status,
|
||||||
next.inventory_status,
|
|
||||||
next.delivery_status,
|
next.delivery_status,
|
||||||
next.result_code,
|
next.result_code,
|
||||||
next.result_message,
|
next.result_message,
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
import { query } from '../db/client.js'
|
|
||||||
import type {
|
|
||||||
WebhookEventCreateInput,
|
|
||||||
WebhookEventListQueryInput,
|
|
||||||
WebhookEventUpdatePatch,
|
|
||||||
} from '../types/repository-inputs.js'
|
|
||||||
import type {
|
|
||||||
WebhookEventListQueryResult,
|
|
||||||
WebhookEventRow,
|
|
||||||
} from '../types/repository-rows.js'
|
|
||||||
|
|
||||||
export async function createWebhookEvent(input: WebhookEventCreateInput): Promise<WebhookEventRow | null> {
|
|
||||||
const result = await query<WebhookEventRow>(
|
|
||||||
`
|
|
||||||
INSERT INTO webhook_events (
|
|
||||||
provider,
|
|
||||||
platform,
|
|
||||||
shop_id,
|
|
||||||
shop_name,
|
|
||||||
event_type,
|
|
||||||
event_key,
|
|
||||||
signature_valid,
|
|
||||||
headers_json,
|
|
||||||
query_json,
|
|
||||||
body_json,
|
|
||||||
processed,
|
|
||||||
process_error,
|
|
||||||
related_order_id,
|
|
||||||
created_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::jsonb, $11, $12, $13, $14)
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
input.provider,
|
|
||||||
input.platform,
|
|
||||||
input.shopId || '',
|
|
||||||
input.shopName || '',
|
|
||||||
input.eventType,
|
|
||||||
input.eventKey,
|
|
||||||
Boolean(input.signatureValid),
|
|
||||||
input.headersJson || '{}',
|
|
||||||
input.queryJson || '{}',
|
|
||||||
input.bodyJson || '{}',
|
|
||||||
Boolean(input.processed),
|
|
||||||
input.processError || '',
|
|
||||||
input.relatedOrderId || null,
|
|
||||||
input.createdAt,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateWebhookEvent(
|
|
||||||
eventId: number | string,
|
|
||||||
patch: WebhookEventUpdatePatch,
|
|
||||||
): Promise<WebhookEventRow | null> {
|
|
||||||
const current = await getWebhookEventById(eventId)
|
|
||||||
if (!current) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = { ...current, ...patch }
|
|
||||||
const result = await query<WebhookEventRow>(
|
|
||||||
`
|
|
||||||
UPDATE webhook_events
|
|
||||||
SET
|
|
||||||
processed = $1,
|
|
||||||
process_error = $2,
|
|
||||||
related_order_id = $3
|
|
||||||
WHERE id = $4
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[Boolean(next.processed), next.process_error || '', next.related_order_id || null, Number(eventId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWebhookEventById(eventId: number | string): Promise<WebhookEventRow | null> {
|
|
||||||
const result = await query<WebhookEventRow>('SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', [
|
|
||||||
Number(eventId),
|
|
||||||
])
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listWebhookEvents({
|
|
||||||
page = 1,
|
|
||||||
pageSize = 20,
|
|
||||||
provider = '',
|
|
||||||
platform = '',
|
|
||||||
platformOrderId = '',
|
|
||||||
processed = '',
|
|
||||||
visibility = 'important',
|
|
||||||
relatedOrderId = '',
|
|
||||||
dateFrom = '',
|
|
||||||
dateTo = '',
|
|
||||||
}: WebhookEventListQueryInput = {}): Promise<WebhookEventListQueryResult> {
|
|
||||||
const offset = (page - 1) * pageSize
|
|
||||||
const filters: string[] = []
|
|
||||||
const params: unknown[] = []
|
|
||||||
|
|
||||||
if (provider) {
|
|
||||||
params.push(provider)
|
|
||||||
filters.push(`provider = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform) {
|
|
||||||
params.push(platform)
|
|
||||||
filters.push(`platform = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platformOrderId) {
|
|
||||||
params.push(`%${platformOrderId}%`)
|
|
||||||
filters.push(`(event_key ILIKE $${params.length} OR body_json::text ILIKE $${params.length})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (processed === '0' || processed === '1') {
|
|
||||||
params.push(processed === '1')
|
|
||||||
filters.push(`processed = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibility === 'ignored') {
|
|
||||||
filters.push(`process_error LIKE 'ignored_%'`)
|
|
||||||
} else if (visibility === 'important') {
|
|
||||||
filters.push(`(process_error = '' OR process_error NOT LIKE 'ignored_%')`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relatedOrderId) {
|
|
||||||
params.push(Number(relatedOrderId))
|
|
||||||
filters.push(`related_order_id = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateFrom) {
|
|
||||||
params.push(dateFrom)
|
|
||||||
filters.push(`created_at >= $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateTo) {
|
|
||||||
params.push(dateTo)
|
|
||||||
filters.push(`created_at <= $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
|
||||||
const totalResult = await query<{ [column: string]: unknown, total: number }>(
|
|
||||||
`SELECT COUNT(*)::int AS total FROM webhook_events ${whereClause}`,
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
params.push(pageSize)
|
|
||||||
params.push(offset)
|
|
||||||
const itemsResult = await query<WebhookEventRow>(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM webhook_events
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
|
||||||
`,
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: itemsResult.rows,
|
|
||||||
total: Number(totalResult.rows[0]?.total || 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listWebhookEventsByOrderId(orderId: number | string): Promise<WebhookEventRow[]> {
|
|
||||||
const result = await query<WebhookEventRow>(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM webhook_events
|
|
||||||
WHERE related_order_id = $1
|
|
||||||
ORDER BY id DESC
|
|
||||||
`,
|
|
||||||
[Number(orderId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
@@ -3,13 +3,11 @@ import { Router } from "express";
|
|||||||
import authRouter from "./admin/auth.js";
|
import authRouter from "./admin/auth.js";
|
||||||
import auditLogsRouter from "./admin/audit-logs.js";
|
import auditLogsRouter from "./admin/audit-logs.js";
|
||||||
import dashboardRouter from "./admin/dashboard.js";
|
import dashboardRouter from "./admin/dashboard.js";
|
||||||
import inventoryRouter from "./admin/inventory.js";
|
|
||||||
import ordersRouter from "./admin/orders.js";
|
import ordersRouter from "./admin/orders.js";
|
||||||
import platformConfigRouter from "./admin/platform-config.js";
|
import platformConfigRouter from "./admin/platform-config.js";
|
||||||
import { requireAdminSession } from "./admin/shared.js";
|
import { requireAdminSession } from "./admin/shared.js";
|
||||||
import tasksRouter from "./admin/tasks.js";
|
import tasksRouter from "./admin/tasks.js";
|
||||||
import usersRouter from "./admin/users.js";
|
import usersRouter from "./admin/users.js";
|
||||||
import webhookEventsRouter from "./admin/webhook-events.js";
|
|
||||||
import { buildNotFoundPayload } from "../utils/http.js";
|
import { buildNotFoundPayload } from "../utils/http.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -22,8 +20,6 @@ router.use(auditLogsRouter);
|
|||||||
router.use(platformConfigRouter);
|
router.use(platformConfigRouter);
|
||||||
router.use(ordersRouter);
|
router.use(ordersRouter);
|
||||||
router.use(tasksRouter);
|
router.use(tasksRouter);
|
||||||
router.use(inventoryRouter);
|
|
||||||
router.use(webhookEventsRouter);
|
|
||||||
|
|
||||||
router.use((req, res) => {
|
router.use((req, res) => {
|
||||||
res.status(404).json(buildNotFoundPayload(req));
|
res.status(404).json(buildNotFoundPayload(req));
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createAdminInventoryItem,
|
|
||||||
importAdminInventoryItems,
|
|
||||||
invalidateAdminInventoryItem,
|
|
||||||
releaseAdminInventoryItem,
|
|
||||||
} from "../../services/admin/write/inventory.js";
|
|
||||||
import {
|
|
||||||
getAdminInventoryItems,
|
|
||||||
getAdminInventorySkuSuggestions,
|
|
||||||
} from "../../services/admin/admin-read-service.js";
|
|
||||||
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
|
||||||
import type {
|
|
||||||
AdminInventoryCreateRouteBody,
|
|
||||||
AdminInventoryImportRouteBody,
|
|
||||||
AdminInventoryInvalidateRouteBody,
|
|
||||||
AdminInventoryRouteParams,
|
|
||||||
AdminInventoryRouteQuery,
|
|
||||||
AdminInventorySkuSuggestionRouteQuery,
|
|
||||||
} from "../../types/admin-route-inputs.js";
|
|
||||||
import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.use("/inventory", requireAdminRoles(["admin", "operator"]));
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/inventory",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminInventoryItems(
|
|
||||||
req.query as AdminInventoryRouteQuery,
|
|
||||||
req.adminSession || null
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取库存列表失败",
|
|
||||||
scope: "[admin/inventory]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/inventory/sku-suggestions",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminInventorySkuSuggestions(
|
|
||||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
|
||||||
req.adminSession || null
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取内部 SKU 建议失败",
|
|
||||||
scope: "[admin/inventory/sku-suggestions]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/inventory",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
|
||||||
{
|
|
||||||
successMessage: "库存项已新增",
|
|
||||||
errorMessage: "新增库存项失败",
|
|
||||||
scope: "[admin/inventory]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/inventory/import",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
|
||||||
{
|
|
||||||
successMessage: "导入完成",
|
|
||||||
errorMessage: "导入库存凭据失败",
|
|
||||||
scope: "[admin/inventory/import]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/inventory/:inventoryItemId/release",
|
|
||||||
requireAdminRoles(["admin"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
releaseAdminInventoryItem(
|
|
||||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "库存项已释放",
|
|
||||||
errorMessage: "释放库存项失败",
|
|
||||||
scope: "[admin/inventory/:inventoryItemId/release]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminInventoryMutationResponse;
|
|
||||||
|
|
||||||
return {
|
|
||||||
action: "inventory_item_released",
|
|
||||||
targetType: "inventory_item",
|
|
||||||
targetId: String(
|
|
||||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
|
||||||
),
|
|
||||||
data: {
|
|
||||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
|
||||||
skuCode: result.inventoryItem?.skuCode,
|
|
||||||
displayValue: result.inventoryItem?.displayValue,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/inventory/:inventoryItemId/invalidate",
|
|
||||||
requireAdminRoles(["admin"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
invalidateAdminInventoryItem(
|
|
||||||
(req.params as AdminInventoryRouteParams).inventoryItemId,
|
|
||||||
req.body as AdminInventoryInvalidateRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "库存项已作废",
|
|
||||||
errorMessage: "作废库存项失败",
|
|
||||||
scope: "[admin/inventory/:inventoryItemId/invalidate]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminInventoryMutationResponse;
|
|
||||||
|
|
||||||
return {
|
|
||||||
action: "inventory_item_invalidated",
|
|
||||||
targetType: "inventory_item",
|
|
||||||
targetId: String(
|
|
||||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
|
||||||
),
|
|
||||||
data: {
|
|
||||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
|
||||||
skuCode: result.inventoryItem?.skuCode,
|
|
||||||
invalidReason: result.inventoryItem?.invalidReason,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -4,7 +4,6 @@ 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'
|
||||||
@@ -16,7 +15,6 @@ type AdminUserMutationResult = {
|
|||||||
username: string
|
username: string
|
||||||
role: string
|
role: string
|
||||||
status?: string
|
status?: string
|
||||||
inventoryGroupCodes?: string[]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,27 +95,6 @@ router.post('/users/:userId/status', createJsonHandler(
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
router.post('/users/:userId/inventory-groups', createJsonHandler(
|
|
||||||
(req) => updateManagedAdminUserInventoryGroups(String(req.params.userId), req.body, req.adminSession),
|
|
||||||
{
|
|
||||||
successMessage: '用户库存组已更新',
|
|
||||||
errorMessage: '更新用户库存组失败',
|
|
||||||
scope: '[admin/users/:userId/inventory-groups]',
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as AdminUserMutationResult
|
|
||||||
return {
|
|
||||||
action: 'admin_user_inventory_groups_updated',
|
|
||||||
targetType: 'admin_user',
|
|
||||||
targetId: String(result.user.userId),
|
|
||||||
data: {
|
|
||||||
username: result.user.username,
|
|
||||||
inventoryGroupCodes: result.user.inventoryGroupCodes,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/users/:userId/reset-password', createJsonHandler(
|
router.post('/users/:userId/reset-password', createJsonHandler(
|
||||||
(req) => resetManagedAdminUserPassword(String(req.params.userId), req.body),
|
(req) => resetManagedAdminUserPassword(String(req.params.userId), req.body),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getAdminWebhookEventDetail,
|
|
||||||
getAdminWebhookEvents,
|
|
||||||
} from "../../services/admin/admin-read-service.js";
|
|
||||||
import { createHttpError } from "../../utils/http.js";
|
|
||||||
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
|
||||||
import type {
|
|
||||||
AdminWebhookEventRouteParams,
|
|
||||||
AdminWebhookEventRouteQuery,
|
|
||||||
} from "../../types/admin-route-inputs.js";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.use("/webhook-events", requireAdminRoles(["admin", "operator"]));
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/webhook-events",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
|
||||||
{
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取 webhook 列表失败",
|
|
||||||
scope: "[admin/webhook-events]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/webhook-events/:eventId",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminWebhookEventDetail(
|
|
||||||
(req.params as AdminWebhookEventRouteParams).eventId
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取 webhook 详情失败",
|
|
||||||
scope: "[admin/webhook-events/:eventId]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/webhook-events/:eventId/replay",
|
|
||||||
requireAdminRoles(["admin"]),
|
|
||||||
createJsonHandler(
|
|
||||||
() => {
|
|
||||||
throw createHttpError("快手轻量后端不支持重放旧 Webhook", {
|
|
||||||
statusCode: 409,
|
|
||||||
errorCode: "admin_webhook_replay_disabled",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
successMessage: "Webhook 已重放",
|
|
||||||
errorMessage: "重放 webhook 失败",
|
|
||||||
scope: "[admin/webhook-events/:eventId/replay]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
return {
|
|
||||||
action: "webhook_replayed",
|
|
||||||
targetType: "webhook_event",
|
|
||||||
targetId: String(
|
|
||||||
(req.params as AdminWebhookEventRouteParams).eventId
|
|
||||||
),
|
|
||||||
data: {
|
|
||||||
disabled: true,
|
|
||||||
result: data,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -3,7 +3,7 @@ import { Router } from 'express'
|
|||||||
import { createOpen91Order } from '../services/open-91/order-create-service.js'
|
import { createOpen91Order } from '../services/open-91/order-create-service.js'
|
||||||
import { queryOpen91Order } from '../services/open-91/order-query-service.js'
|
import { queryOpen91Order } from '../services/open-91/order-query-service.js'
|
||||||
import { buildOpen91ErrorResponse } from '../services/open-91/shared.js'
|
import { buildOpen91ErrorResponse } from '../services/open-91/shared.js'
|
||||||
import { createRequestId, logWebhook } from '../utils/logger.js'
|
import { createRequestId, logIntegration } from '../utils/logger.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ router.post('/orders/create', async (req, res) => {
|
|||||||
const requestId = createRequestId('91')
|
const requestId = createRequestId('91')
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
logWebhook('[open-91/create]', '收到 91卡券异步下单请求', {
|
logIntegration('[open-91/create]', '收到 91卡券异步下单请求', {
|
||||||
requestId,
|
requestId,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
originalUrl: req.originalUrl,
|
originalUrl: req.originalUrl,
|
||||||
@@ -21,7 +21,7 @@ router.post('/orders/create', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createOpen91Order(req.body, { requestId })
|
const result = await createOpen91Order(req.body, { requestId })
|
||||||
logWebhook('[open-91/create]', '91卡券异步下单处理完成', {
|
logIntegration('[open-91/create]', '91卡券异步下单处理完成', {
|
||||||
requestId,
|
requestId,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
result,
|
result,
|
||||||
@@ -30,7 +30,7 @@ router.post('/orders/create', async (req, res) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '系统错误'
|
const message = error instanceof Error ? error.message : '系统错误'
|
||||||
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
||||||
logWebhook('[open-91/create]', '91卡券异步下单处理失败', {
|
logIntegration('[open-91/create]', '91卡券异步下单处理失败', {
|
||||||
requestId,
|
requestId,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
error,
|
error,
|
||||||
@@ -43,7 +43,7 @@ router.post('/orders/query', async (req, res) => {
|
|||||||
const requestId = createRequestId('91')
|
const requestId = createRequestId('91')
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
logWebhook('[open-91/query]', '收到 91卡券订单查询请求', {
|
logIntegration('[open-91/query]', '收到 91卡券订单查询请求', {
|
||||||
requestId,
|
requestId,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
originalUrl: req.originalUrl,
|
originalUrl: req.originalUrl,
|
||||||
@@ -53,7 +53,7 @@ router.post('/orders/query', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await queryOpen91Order(req.body, { requestId })
|
const result = await queryOpen91Order(req.body, { requestId })
|
||||||
logWebhook('[open-91/query]', '91卡券订单查询处理完成', {
|
logIntegration('[open-91/query]', '91卡券订单查询处理完成', {
|
||||||
requestId,
|
requestId,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
result,
|
result,
|
||||||
@@ -62,7 +62,7 @@ router.post('/orders/query', async (req, res) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '系统错误'
|
const message = error instanceof Error ? error.message : '系统错误'
|
||||||
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
||||||
logWebhook('[open-91/query]', '91卡券订单查询处理失败', {
|
logIntegration('[open-91/query]', '91卡券订单查询处理失败', {
|
||||||
requestId,
|
requestId,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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'
|
||||||
@@ -23,7 +22,6 @@ export type AdminSession = {
|
|||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
role: AdminRole
|
role: AdminRole
|
||||||
allowedInventoryGroups: string[]
|
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +142,6 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
|
|||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +156,6 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
|
|||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
role: session.role,
|
role: session.role,
|
||||||
inventoryGroupCodes: session.allowedInventoryGroups,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,22 +231,6 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateManagedAdminUserInventoryGroups(
|
|
||||||
userId: number | string,
|
|
||||||
payload: JsonObject = {},
|
|
||||||
session: AdminSession,
|
|
||||||
): Promise<JsonObject> {
|
|
||||||
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(
|
export async function updateManagedAdminUserRole(
|
||||||
userId: number | string,
|
userId: number | string,
|
||||||
payload: JsonObject = {},
|
payload: JsonObject = {},
|
||||||
@@ -358,7 +338,6 @@ function createAdminSession(user: AdminUserRow): JsonObject {
|
|||||||
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),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,14 +472,7 @@ function mapAdminUser(user: AdminUserRow | null): JsonObject {
|
|||||||
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: unknown): string[] {
|
|
||||||
return Array.from(new Set((Array.isArray(values) ? values : [])
|
|
||||||
.map((value) => String(value || '').trim())
|
|
||||||
.filter(Boolean)))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ export async function getAdminDashboardSummary() {
|
|||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
FROM fulfillment_tasks
|
||||||
WHERE task_status IN ('paid', 'link_generated')
|
WHERE task_status IN ('paid', 'link_generated', 'pending_binding_prepare', 'waiting_binding')
|
||||||
OR (inventory_status = 'reserved' AND task_status NOT IN ('claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed', 'expired'))
|
|
||||||
) AS paid_pending_claim,
|
) AS paid_pending_claim,
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
@@ -29,13 +28,8 @@ export async function getAdminDashboardSummary() {
|
|||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
FROM fulfillment_tasks
|
||||||
WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory')
|
WHERE task_status IN ('retry_pending', 'manual_review')
|
||||||
) AS abnormal_tasks,
|
) AS abnormal_tasks
|
||||||
(
|
|
||||||
SELECT COUNT(DISTINCT sku_code)::int
|
|
||||||
FROM inventory_items
|
|
||||||
WHERE status = 'available'
|
|
||||||
) AS sku_with_inventory
|
|
||||||
`,
|
`,
|
||||||
[todayPrefix],
|
[todayPrefix],
|
||||||
)
|
)
|
||||||
@@ -47,6 +41,5 @@ export async function getAdminDashboardSummary() {
|
|||||||
claimingTasks: Number(summary?.claiming_tasks || 0),
|
claimingTasks: Number(summary?.claiming_tasks || 0),
|
||||||
redeemedToday: Number(summary?.redeemed_today || 0),
|
redeemedToday: Number(summary?.redeemed_today || 0),
|
||||||
abnormalTasks: Number(summary?.abnormal_tasks || 0),
|
abnormalTasks: Number(summary?.abnormal_tasks || 0),
|
||||||
skuWithInventory: Number(summary?.sku_with_inventory || 0),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { getInventoryItemById } from '../../repositories/inventory-repo.js'
|
|
||||||
import { getOrderById } from '../../repositories/order-repo.js'
|
|
||||||
import { getTaskById } from '../../repositories/task-repo.js'
|
|
||||||
import { createHttpError } from '../../utils/http.js'
|
|
||||||
import { mapAdminTaskSummary } from './admin-task-read-helpers.js'
|
|
||||||
|
|
||||||
import type { AdminInventoryItemListItem } from '../../types/admin-read-models.js'
|
|
||||||
import type { InventoryItemRow } from '../../types/repository-rows.js'
|
|
||||||
|
|
||||||
export async function mapAdminInventoryListItem(item: InventoryItemRow): Promise<AdminInventoryItemListItem> {
|
|
||||||
const task = item.reserved_by_task_id ? await getTaskById(item.reserved_by_task_id) : null
|
|
||||||
const order = task?.order_id ? await getOrderById(task.order_id) : null
|
|
||||||
const taskSummary = task ? mapAdminTaskSummary(task) : null
|
|
||||||
|
|
||||||
return {
|
|
||||||
inventoryItemId: item.id,
|
|
||||||
skuCode: item.sku_code,
|
|
||||||
batchNo: item.batch_no,
|
|
||||||
credentialType: item.credential_type || 'claim_code',
|
|
||||||
inventoryGroupCode: String(item.inventory_group_code || '').trim(),
|
|
||||||
displayValue: item.display_value,
|
|
||||||
status: item.status,
|
|
||||||
reservedByTaskId: item.reserved_by_task_id,
|
|
||||||
reservedByTaskNo: task?.task_no || '',
|
|
||||||
platformOrderId: order?.platform_order_id || '',
|
|
||||||
systemBindingStatus: item.status === 'consumed' ? 'system_bound' : (taskSummary?.systemBindingStatus || 'pending_binding'),
|
|
||||||
userBindingStatus: item.status === 'consumed' ? 'binding_completed' : (taskSummary?.userBindingStatus || 'not_started'),
|
|
||||||
invalidReason: item.invalid_reason || '',
|
|
||||||
deliveredAt: item.delivered_at,
|
|
||||||
createdAt: item.created_at,
|
|
||||||
updatedAt: item.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRequiredInventoryItem(inventoryItemId: number | string): Promise<InventoryItemRow> {
|
|
||||||
const inventoryItem = await getInventoryItemById(Number(inventoryItemId))
|
|
||||||
|
|
||||||
if (!inventoryItem) {
|
|
||||||
throw createHttpError('库存项不存在', {
|
|
||||||
statusCode: 404,
|
|
||||||
errorCode: 'admin_inventory_not_found',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return inventoryItem
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
|||||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||||
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||||
import { resolveDisplayShopName, resolveOrderItemTitle } from './admin-read-shared-helpers.js'
|
import { resolveDisplayShopName, resolveOrderItemTitle } from './admin-read-shared-helpers.js'
|
||||||
import { buildOrderBindingSummary, getTaskBindingSummaryMap } from './admin-task-read-helpers.js'
|
import { buildOrderBindingSummary } from './admin-task-read-helpers.js'
|
||||||
|
|
||||||
import type { AdminOrderListItem } from '../../types/admin-read-models.js'
|
import type { AdminOrderListItem } from '../../types/admin-read-models.js'
|
||||||
import type { OrderItemRow, OrderListRow } from '../../types/repository-rows.js'
|
import type { OrderItemRow, OrderListRow } from '../../types/repository-rows.js'
|
||||||
@@ -12,8 +12,7 @@ export async function mapAdminOrderListItem(item: OrderListRow): Promise<AdminOr
|
|||||||
listTasksByOrderId(item.id),
|
listTasksByOrderId(item.id),
|
||||||
listOrderItemsByOrderId(item.id),
|
listOrderItemsByOrderId(item.id),
|
||||||
])
|
])
|
||||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id))
|
const bindingSummary = buildOrderBindingSummary(tasks)
|
||||||
const bindingSummary = buildOrderBindingSummary(tasks, taskBindingSummaryMap)
|
|
||||||
const itemSummary = summarizeOrderItems(orderItems)
|
const itemSummary = summarizeOrderItems(orderItems)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
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 { 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'
|
||||||
import { listTaskInventoryBindingsByTaskId } from '../../repositories/task-inventory-binding-repo.js'
|
|
||||||
import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js'
|
import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js'
|
||||||
import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js'
|
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||||
@@ -19,7 +12,6 @@ import {
|
|||||||
canRegenerateClaimLinkForViewer,
|
canRegenerateClaimLinkForViewer,
|
||||||
createAdminViewerContext,
|
createAdminViewerContext,
|
||||||
getTaskPrimaryClaimTokenId,
|
getTaskPrimaryClaimTokenId,
|
||||||
getTaskPrimaryInventoryItemId,
|
|
||||||
isManualDispatchTask,
|
isManualDispatchTask,
|
||||||
mapKuaishouCloudFulfillmentContext,
|
mapKuaishouCloudFulfillmentContext,
|
||||||
mapManualDispatchContext,
|
mapManualDispatchContext,
|
||||||
@@ -31,36 +23,24 @@ import {
|
|||||||
resolveOrderItemDeliveryMode,
|
resolveOrderItemDeliveryMode,
|
||||||
resolveOrderItemTitle,
|
resolveOrderItemTitle,
|
||||||
} from './admin-read-shared-helpers.js'
|
} from './admin-read-shared-helpers.js'
|
||||||
import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
|
|
||||||
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
||||||
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
|
||||||
import {
|
import {
|
||||||
buildOrderBindingSummary,
|
buildOrderBindingSummary,
|
||||||
createTaskBindingSummaryFromBindings,
|
|
||||||
getRequiredTask,
|
getRequiredTask,
|
||||||
getTaskBindingSummary,
|
|
||||||
getTaskBindingSummaryMap,
|
|
||||||
mapAdminTaskEvent,
|
mapAdminTaskEvent,
|
||||||
mapAdminTaskInventoryBinding,
|
|
||||||
mapAdminTaskListItem,
|
mapAdminTaskListItem,
|
||||||
mapAdminTaskSummary,
|
mapAdminTaskSummary,
|
||||||
} from './admin-task-read-helpers.js'
|
} from './admin-task-read-helpers.js'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdminInventoryListResponse,
|
|
||||||
AdminInventorySkuSuggestionResponse,
|
|
||||||
AdminOrderListResponse,
|
AdminOrderListResponse,
|
||||||
AdminTaskListResponse,
|
AdminTaskListResponse,
|
||||||
AdminWebhookEventListResponse,
|
|
||||||
} from '../../types/admin-read-models.js'
|
} from '../../types/admin-read-models.js'
|
||||||
import type {
|
import type {
|
||||||
AdminEntityIdInput,
|
AdminEntityIdInput,
|
||||||
AdminInventoryListQueryInput,
|
|
||||||
AdminInventorySkuSuggestionQueryInput,
|
|
||||||
AdminOrderListQueryInput,
|
AdminOrderListQueryInput,
|
||||||
AdminTaskListQueryInput,
|
AdminTaskListQueryInput,
|
||||||
AdminViewerSessionInput,
|
AdminViewerSessionInput,
|
||||||
AdminWebhookEventListQueryInput,
|
|
||||||
} from '../../types/admin-read-inputs.js'
|
} from '../../types/admin-read-inputs.js'
|
||||||
|
|
||||||
type JsonRecord = Record<string, any>
|
type JsonRecord = Record<string, any>
|
||||||
@@ -94,12 +74,10 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const [items, tasks, webhookEvents] = await Promise.all([
|
const [items, tasks] = await Promise.all([
|
||||||
listOrderItemsByOrderId(order.id),
|
listOrderItemsByOrderId(order.id),
|
||||||
listTasksByOrderId(order.id),
|
listTasksByOrderId(order.id),
|
||||||
listWebhookEventsByOrderId(order.id),
|
|
||||||
])
|
])
|
||||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id))
|
|
||||||
const itemSummary = summarizeOrderItems(items)
|
const itemSummary = summarizeOrderItems(items)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -125,7 +103,7 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
|||||||
rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object'
|
rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object'
|
||||||
? order.raw_payload_json
|
? order.raw_payload_json
|
||||||
: JSON.parse(String(order.raw_payload_json || '{}')),
|
: JSON.parse(String(order.raw_payload_json || '{}')),
|
||||||
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
|
bindingSummary: buildOrderBindingSummary(tasks),
|
||||||
},
|
},
|
||||||
items: items.map((item) => ({
|
items: items.map((item) => ({
|
||||||
orderItemId: item.id,
|
orderItemId: item.id,
|
||||||
@@ -138,20 +116,7 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
|||||||
? item.spec_json
|
? item.spec_json
|
||||||
: JSON.parse(String(item.spec_json || '{}')),
|
: JSON.parse(String(item.spec_json || '{}')),
|
||||||
})),
|
})),
|
||||||
tasks: tasks.map((task) => mapAdminTaskSummary(task, getTaskBindingSummary(taskBindingSummaryMap, task.id))),
|
tasks: tasks.map((task) => mapAdminTaskSummary(task)),
|
||||||
webhookEvents: webhookEvents.map((event) => ({
|
|
||||||
eventId: event.id,
|
|
||||||
provider: event.provider || '',
|
|
||||||
platform: event.platform,
|
|
||||||
shopId: event.shop_id || '',
|
|
||||||
shopName: resolveDisplayShopName(event.provider, event.shop_id, event.shop_name),
|
|
||||||
eventType: event.event_type,
|
|
||||||
eventKey: event.event_key,
|
|
||||||
signatureValid: Boolean(event.signature_valid),
|
|
||||||
processed: Boolean(event.processed),
|
|
||||||
processError: event.process_error,
|
|
||||||
createdAt: event.created_at,
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,15 +137,10 @@ export async function getAdminTasks(
|
|||||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||||
})
|
})
|
||||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id))
|
|
||||||
const viewerContext = createAdminViewerContext(session)
|
const viewerContext = createAdminViewerContext(session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map((item) => mapAdminTaskListItem(
|
items: items.map((item) => mapAdminTaskListItem(item, viewerContext)),
|
||||||
item,
|
|
||||||
getTaskBindingSummary(taskBindingSummaryMap, item.id),
|
|
||||||
viewerContext,
|
|
||||||
)),
|
|
||||||
pagination: { page, pageSize, total },
|
pagination: { page, pageSize, total },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,20 +158,16 @@ export async function getAdminTaskDetail(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const [order, claimToken, inventoryItem, taskEvents, inventoryBindings] = await Promise.all([
|
const [order, claimToken, taskEvents] = await Promise.all([
|
||||||
getOrderById(task.order_id),
|
getOrderById(task.order_id),
|
||||||
getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null),
|
getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null),
|
||||||
getTaskPrimaryInventoryItemId(task) ? getInventoryItemById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null),
|
|
||||||
listTaskEventsByTaskId(task.id),
|
listTaskEventsByTaskId(task.id),
|
||||||
listTaskInventoryBindingsByTaskId(task.id),
|
|
||||||
])
|
])
|
||||||
const orderItems = order ? await listOrderItemsByOrderId(order.id) : []
|
const orderItems = order ? await listOrderItemsByOrderId(order.id) : []
|
||||||
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
|
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
|
||||||
const taskContext = parseTaskContext(task)
|
const taskContext = parseTaskContext(task)
|
||||||
const taskState = parseTaskState(task)
|
const taskState = parseTaskState(task)
|
||||||
const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings)
|
|
||||||
const viewerContext = createAdminViewerContext(session)
|
const viewerContext = createAdminViewerContext(session)
|
||||||
const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext))
|
|
||||||
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
|
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
|
||||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
||||||
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment)
|
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment)
|
||||||
@@ -221,9 +177,8 @@ export async function getAdminTaskDetail(
|
|||||||
...task,
|
...task,
|
||||||
sku_code: orderItem?.sku_code || '',
|
sku_code: orderItem?.sku_code || '',
|
||||||
sku_name: orderItem?.sku_name || '',
|
sku_name: orderItem?.sku_name || '',
|
||||||
inventory_display_value: inventoryItem?.display_value || '',
|
|
||||||
claim_token: claimToken?.token || '',
|
claim_token: claimToken?.token || '',
|
||||||
}, taskBindingSummary, viewerContext),
|
}, viewerContext),
|
||||||
order: order
|
order: order
|
||||||
? {
|
? {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
@@ -253,17 +208,6 @@ export async function getAdminTaskDetail(
|
|||||||
claimUrl,
|
claimUrl,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
inventory: inventoryItem
|
|
||||||
? {
|
|
||||||
inventoryItemId: inventoryItem.id,
|
|
||||||
skuCode: inventoryItem.sku_code,
|
|
||||||
batchNo: inventoryItem.batch_no,
|
|
||||||
credentialType: inventoryItem.credential_type || 'claim_code',
|
|
||||||
displayValue: viewerContext.canViewSensitiveTaskData ? inventoryItem.display_value : '',
|
|
||||||
status: inventoryItem.status,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
inventoryBindings: mappedInventoryBindings,
|
|
||||||
artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {},
|
artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {},
|
||||||
screenshotUrl,
|
screenshotUrl,
|
||||||
review: {
|
review: {
|
||||||
@@ -279,8 +223,7 @@ export async function getAdminTaskDetail(
|
|||||||
operations: {
|
operations: {
|
||||||
canRetry: viewerContext.canManageTaskLifecycle
|
canRetry: viewerContext.canManageTaskLifecycle
|
||||||
&& !isManualDispatchTask(task)
|
&& !isManualDispatchTask(task)
|
||||||
&& ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
&& ['retry_pending', 'manual_review'].includes(task.task_status),
|
||||||
canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease),
|
|
||||||
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
|
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
|
||||||
canClose: canViewerCloseTask(task, viewerContext),
|
canClose: canViewerCloseTask(task, viewerContext),
|
||||||
canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
|
canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
|
||||||
@@ -319,96 +262,3 @@ export async function getAdminTaskScreenshotPath(
|
|||||||
errorCode: 'admin_task_screenshot_not_found',
|
errorCode: 'admin_task_screenshot_not_found',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminInventoryItems(
|
|
||||||
query: AdminInventoryListQueryInput = {},
|
|
||||||
session: AdminViewerSessionInput | null = null,
|
|
||||||
): Promise<AdminInventoryListResponse> {
|
|
||||||
const page = normalizePage(query.page)
|
|
||||||
const pageSize = normalizePageSize(query.pageSize)
|
|
||||||
const viewerContext = createAdminViewerContext(session)
|
|
||||||
const { items, total } = await listInventoryItems({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
skuCode: String(query.skuCode || '').trim(),
|
|
||||||
credentialType: String(query.credentialType || '').trim(),
|
|
||||||
status: String(query.status || '').trim(),
|
|
||||||
batchNo: String(query.batchNo || '').trim(),
|
|
||||||
inventoryGroupCode: String(query.inventoryGroupCode || '').trim(),
|
|
||||||
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: await Promise.all(items.map((item) => mapAdminInventoryListItem(item))),
|
|
||||||
pagination: { page, pageSize, total },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminInventorySkuSuggestions(
|
|
||||||
query: AdminInventorySkuSuggestionQueryInput = {},
|
|
||||||
session: AdminViewerSessionInput | null = null,
|
|
||||||
): Promise<AdminInventorySkuSuggestionResponse> {
|
|
||||||
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))
|
|
||||||
: 50
|
|
||||||
const items = await listInventorySkuSuggestions({
|
|
||||||
credentialType,
|
|
||||||
keyword,
|
|
||||||
limit,
|
|
||||||
inventoryGroupCode,
|
|
||||||
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: items.map((item) => ({
|
|
||||||
skuCode: String(item.sku_code || '').trim(),
|
|
||||||
credentialType: String(item.credential_type || '').trim() || 'claim_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,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminWebhookEvents(
|
|
||||||
query: AdminWebhookEventListQueryInput = {},
|
|
||||||
): Promise<AdminWebhookEventListResponse> {
|
|
||||||
const page = normalizePage(query.page)
|
|
||||||
const pageSize = normalizePageSize(query.pageSize)
|
|
||||||
const { items, total } = await listWebhookEvents({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
provider: String(query.provider || '').trim(),
|
|
||||||
platform: String(query.platform || '').trim(),
|
|
||||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
|
||||||
processed: String(query.processed || '').trim(),
|
|
||||||
visibility: String(query.visibility || '').trim() || 'important',
|
|
||||||
relatedOrderId: String(query.relatedOrderId || '').trim(),
|
|
||||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
|
||||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: await Promise.all(items.map((item) => mapAdminWebhookEvent(item))),
|
|
||||||
pagination: { page, pageSize, total },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminWebhookEventDetail(eventId: AdminEntityIdInput): Promise<JsonRecord> {
|
|
||||||
const event = await getWebhookEventById(Number(eventId))
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
throw createHttpError('Webhook 事件不存在', {
|
|
||||||
statusCode: 404,
|
|
||||||
errorCode: 'admin_webhook_not_found',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapAdminWebhookEvent(event, { includeRaw: true })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,17 +16,13 @@ type TaskLike = Partial<TaskRow> & {
|
|||||||
|
|
||||||
export type AdminViewerContext = {
|
export type AdminViewerContext = {
|
||||||
role: string
|
role: string
|
||||||
allowedInventoryGroupCodes: string[] | null
|
|
||||||
restrictInventoryGroups: boolean
|
|
||||||
canViewSensitiveTaskData: boolean
|
canViewSensitiveTaskData: boolean
|
||||||
canManageSensitiveInventory: boolean
|
|
||||||
canManageTaskLifecycle: boolean
|
canManageTaskLifecycle: boolean
|
||||||
canOperateAssistedTask: boolean
|
canOperateAssistedTask: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type RedeemResolutionAttempt = {
|
type RedeemResolutionAttempt = {
|
||||||
attempt: number
|
attempt: number
|
||||||
inventoryItemId: number | null
|
|
||||||
codeMasked: string
|
codeMasked: string
|
||||||
credentialType: string
|
credentialType: string
|
||||||
outcome: string
|
outcome: string
|
||||||
@@ -166,11 +162,6 @@ export function mapRedeemResolutionContext(value: unknown): JsonRecord | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTaskPrimaryInventoryItemId(task: TaskLike | null | undefined): number | null {
|
|
||||||
const value = Number(task?.primary_inventory_item_id || 0)
|
|
||||||
return Number.isFinite(value) && value > 0 ? value : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): number | null {
|
export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): number | null {
|
||||||
const value = Number(task?.primary_claim_token_id || 0)
|
const value = Number(task?.primary_claim_token_id || 0)
|
||||||
return Number.isFinite(value) && value > 0 ? value : null
|
return Number.isFinite(value) && value > 0 ? value : null
|
||||||
@@ -178,16 +169,10 @@ export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): n
|
|||||||
|
|
||||||
export function createAdminViewerContext(session: AdminViewerSessionInput | null = null): AdminViewerContext {
|
export function createAdminViewerContext(session: AdminViewerSessionInput | null = null): AdminViewerContext {
|
||||||
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',
|
|
||||||
canManageTaskLifecycle: role === 'admin' || role === 'operator',
|
canManageTaskLifecycle: role === 'admin' || role === 'operator',
|
||||||
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
|
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
|
||||||
}
|
}
|
||||||
@@ -297,38 +282,6 @@ export function canRegenerateClaimLinkForViewer(task: TaskLike, viewerContext: A
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canViewerAccessTaskInventoryGroup(
|
|
||||||
task: TaskLike,
|
|
||||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
|
||||||
): boolean {
|
|
||||||
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: unknown): string[] {
|
|
||||||
return Array.from(new Set((Array.isArray(values) ? values : [])
|
|
||||||
.map((value) => String(value || '').trim())
|
|
||||||
.filter(Boolean)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | null {
|
function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | null {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return null
|
return null
|
||||||
@@ -337,7 +290,6 @@ function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | n
|
|||||||
const record = value as JsonRecord
|
const record = value as JsonRecord
|
||||||
return {
|
return {
|
||||||
attempt: Math.max(1, Number(record.attempt || 1)),
|
attempt: Math.max(1, Number(record.attempt || 1)),
|
||||||
inventoryItemId: Number(record.inventoryItemId || 0) || null,
|
|
||||||
codeMasked: String(record.codeMasked || '').trim(),
|
codeMasked: String(record.codeMasked || '').trim(),
|
||||||
credentialType: String(record.credentialType || '').trim(),
|
credentialType: String(record.credentialType || '').trim(),
|
||||||
outcome: String(record.outcome || '').trim(),
|
outcome: String(record.outcome || '').trim(),
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { getTaskById } from '../../repositories/task-repo.js'
|
import { getTaskById } from '../../repositories/task-repo.js'
|
||||||
import { listTaskInventoryBindingSummariesByTaskIds } from '../../repositories/task-inventory-binding-repo.js'
|
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { safeParseJson } from './admin-query-utils.js'
|
import { safeParseJson } from './admin-query-utils.js'
|
||||||
import {
|
import {
|
||||||
createAdminViewerContext,
|
createAdminViewerContext,
|
||||||
getTaskPrimaryClaimTokenId,
|
getTaskPrimaryClaimTokenId,
|
||||||
getTaskPrimaryInventoryItemId,
|
|
||||||
parseTaskContext,
|
|
||||||
} from './admin-read-shared-helpers.js'
|
} from './admin-read-shared-helpers.js'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -16,8 +13,6 @@ import type {
|
|||||||
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
|
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
|
||||||
import type {
|
import type {
|
||||||
TaskEventRow,
|
TaskEventRow,
|
||||||
TaskInventoryBindingRow,
|
|
||||||
TaskInventoryBindingSummaryRow,
|
|
||||||
TaskRow,
|
TaskRow,
|
||||||
} from '../../types/repository-rows.js'
|
} from '../../types/repository-rows.js'
|
||||||
import type { AdminViewerContext } from './admin-read-shared-helpers.js'
|
import type { AdminViewerContext } from './admin-read-shared-helpers.js'
|
||||||
@@ -29,10 +24,6 @@ type TaskBindingState = {
|
|||||||
userBindingStatus: string
|
userBindingStatus: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskInventoryBindingLike = TaskInventoryBindingRow & {
|
|
||||||
inventory_item_status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type OrderBindingSummary = {
|
type OrderBindingSummary = {
|
||||||
totalTaskCount: number
|
totalTaskCount: number
|
||||||
systemBoundTaskCount: number
|
systemBoundTaskCount: number
|
||||||
@@ -72,7 +63,6 @@ export function mapAdminTaskSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
|
export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
|
||||||
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
|
|
||||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -82,7 +72,6 @@ export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
|
|||||||
deliveryStatus: task.delivery_status,
|
deliveryStatus: task.delivery_status,
|
||||||
resultCode: task.result_code,
|
resultCode: task.result_code,
|
||||||
resultMessage: task.result_message,
|
resultMessage: task.result_message,
|
||||||
inventoryItemId,
|
|
||||||
primaryClaimTokenId,
|
primaryClaimTokenId,
|
||||||
lastError: task.last_error,
|
lastError: task.last_error,
|
||||||
updatedAt: task.updated_at,
|
updatedAt: task.updated_at,
|
||||||
@@ -104,10 +93,10 @@ export async function getRequiredTask(taskId: number | string): Promise<TaskRow>
|
|||||||
|
|
||||||
export function mapAdminTaskListItem(
|
export function mapAdminTaskListItem(
|
||||||
task: TaskRow,
|
task: TaskRow,
|
||||||
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
|
||||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
||||||
): AdminTaskListItem {
|
): AdminTaskListItem {
|
||||||
const binding = buildTaskBindingState(task)
|
const binding = buildTaskBindingState(task)
|
||||||
|
const bindingSummary = createEmptyTaskBindingSummary()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@@ -134,8 +123,6 @@ export function mapAdminTaskListItem(
|
|||||||
lastError: task.last_error,
|
lastError: task.last_error,
|
||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
updatedAt: task.updated_at,
|
updatedAt: task.updated_at,
|
||||||
inventoryDisplayMasked: viewerContext.canViewSensitiveTaskData ? maskCode(task.primary_inventory_display_value) : '',
|
|
||||||
inventoryCredentialType: String(task.primary_inventory_credential_type || '').trim(),
|
|
||||||
claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '',
|
claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '',
|
||||||
screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''),
|
screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''),
|
||||||
}
|
}
|
||||||
@@ -162,92 +149,18 @@ export function createEmptyTaskBindingSummary(): AdminTaskBindingSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTaskBindingSummaryMap(taskIds: Array<number | string> = []): Promise<Map<number, AdminTaskBindingSummary>> {
|
|
||||||
const rows = await listTaskInventoryBindingSummariesByTaskIds(taskIds)
|
|
||||||
const output = new Map<number, AdminTaskBindingSummary>()
|
|
||||||
|
|
||||||
for (const row of rows as TaskInventoryBindingSummaryRow[]) {
|
|
||||||
output.set(Number(row.task_id), {
|
|
||||||
totalBindingCount: Number(row.total_binding_count || 0),
|
|
||||||
reservedBindingCount: Number(row.reserved_binding_count || 0),
|
|
||||||
consumedBindingCount: Number(row.consumed_binding_count || 0),
|
|
||||||
releasedBindingCount: Number(row.released_binding_count || 0),
|
|
||||||
roleKeys: Array.isArray(row.role_keys)
|
|
||||||
? row.role_keys.map((value) => String(value || '').trim()).filter(Boolean)
|
|
||||||
: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTaskBindingSummary(
|
|
||||||
summaryMap: Map<number, AdminTaskBindingSummary>,
|
|
||||||
taskId: number | string,
|
|
||||||
): AdminTaskBindingSummary {
|
|
||||||
return summaryMap.get(Number(taskId)) || createEmptyTaskBindingSummary()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTaskBindingSummaryFromBindings(
|
|
||||||
bindings: Array<Partial<TaskInventoryBindingRow>> | null | undefined = [],
|
|
||||||
): AdminTaskBindingSummary {
|
|
||||||
const normalizedBindings = Array.isArray(bindings) ? bindings : []
|
|
||||||
const roleKeys = Array.from(new Set(normalizedBindings
|
|
||||||
.map((binding) => String(binding?.role_key || '').trim())
|
|
||||||
.filter(Boolean)))
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalBindingCount: normalizedBindings.length,
|
|
||||||
reservedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'reserved').length,
|
|
||||||
consumedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'consumed').length,
|
|
||||||
releasedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'released').length,
|
|
||||||
roleKeys,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapAdminTaskInventoryBinding(
|
|
||||||
binding: TaskInventoryBindingLike,
|
|
||||||
task: TaskRow,
|
|
||||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
|
||||||
): JsonRecord {
|
|
||||||
const metadata = safeParseJson(binding.metadata_json)
|
|
||||||
|
|
||||||
return {
|
|
||||||
bindingId: binding.id,
|
|
||||||
inventoryItemId: binding.inventory_item_id,
|
|
||||||
roleKey: String(binding.role_key || '').trim(),
|
|
||||||
quantity: Math.max(1, Number(binding.quantity || 1)),
|
|
||||||
bindingStatus: String(binding.binding_status || '').trim(),
|
|
||||||
inventoryStatus: String(binding.inventory_item_status || '').trim(),
|
|
||||||
skuCode: String(binding.sku_code || '').trim(),
|
|
||||||
batchNo: String(binding.batch_no || '').trim(),
|
|
||||||
credentialType: String(binding.credential_type || 'claim_code').trim() || 'claim_code',
|
|
||||||
displayValue: viewerContext.canViewSensitiveTaskData ? String(binding.display_value || '').trim() : '',
|
|
||||||
invalidReason: String(binding.invalid_reason || '').trim(),
|
|
||||||
consumedAt: binding.consumed_at || null,
|
|
||||||
releasedAt: binding.released_at || null,
|
|
||||||
createdAt: binding.created_at,
|
|
||||||
updatedAt: binding.updated_at,
|
|
||||||
metadata: normalizeRecord(metadata),
|
|
||||||
isPrimary: Number(binding.inventory_item_id || 0) === getTaskPrimaryInventoryItemId(task),
|
|
||||||
canRelease: viewerContext.canManageSensitiveInventory && canReleaseTaskInventoryBinding(task, binding),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildOrderBindingSummary(
|
export function buildOrderBindingSummary(
|
||||||
tasks: TaskRow[] | null | undefined,
|
tasks: TaskRow[] | null | undefined,
|
||||||
taskBindingSummaryMap: Map<number, AdminTaskBindingSummary> = new Map(),
|
|
||||||
): OrderBindingSummary {
|
): OrderBindingSummary {
|
||||||
const normalizedTasks = Array.isArray(tasks) ? tasks : []
|
const normalizedTasks = Array.isArray(tasks) ? tasks : []
|
||||||
const totalTaskCount = normalizedTasks.length
|
const totalTaskCount = normalizedTasks.length
|
||||||
const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task))
|
const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task))
|
||||||
const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length
|
const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length
|
||||||
const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length
|
const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length
|
||||||
const bindingSummaries = normalizedTasks.map((task) => getTaskBindingSummary(taskBindingSummaryMap, task.id))
|
const totalBindingCount = 0
|
||||||
const totalBindingCount = bindingSummaries.reduce((sum, item) => sum + item.totalBindingCount, 0)
|
const reservedBindingCount = 0
|
||||||
const reservedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.reservedBindingCount, 0)
|
const consumedBindingCount = 0
|
||||||
const consumedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.consumedBindingCount, 0)
|
const releasedBindingCount = 0
|
||||||
const releasedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.releasedBindingCount, 0)
|
|
||||||
|
|
||||||
let systemBindingStatus = 'pending_binding'
|
let systemBindingStatus = 'pending_binding'
|
||||||
let userBindingStatus = 'not_started'
|
let userBindingStatus = 'not_started'
|
||||||
@@ -280,7 +193,7 @@ export function buildOrderBindingSummary(
|
|||||||
userBindingStatus = 'binding_exception'
|
userBindingStatus = 'binding_exception'
|
||||||
} else if (systemBoundTaskCount > 0) {
|
} else if (systemBoundTaskCount > 0) {
|
||||||
systemBindingStatus = 'system_bound'
|
systemBindingStatus = 'system_bound'
|
||||||
} else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) {
|
} else if (taskBindings.some((item) => ['manual_review', 'retry_pending'].includes(item.systemBindingStatus))) {
|
||||||
systemBindingStatus = 'binding_exception'
|
systemBindingStatus = 'binding_exception'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,18 +210,6 @@ export function buildOrderBindingSummary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean {
|
|
||||||
if (!task || !binding) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String(binding.binding_status || '').trim() !== 'reserved') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return !['redeemed', 'expired'].includes(String(task.task_status || '').trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTaskBindingState(task: TaskRow | null | undefined): TaskBindingState {
|
function buildTaskBindingState(task: TaskRow | null | undefined): TaskBindingState {
|
||||||
const normalizedStatus = String(task?.task_status || '').trim()
|
const normalizedStatus = String(task?.task_status || '').trim()
|
||||||
|
|
||||||
@@ -327,10 +228,6 @@ function buildTaskBindingState(task: TaskRow | null | undefined): TaskBindingSta
|
|||||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'waiting_inventory') {
|
|
||||||
return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedStatus === 'pending_binding_prepare') {
|
if (normalizedStatus === 'pending_binding_prepare') {
|
||||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||||
}
|
}
|
||||||
@@ -391,24 +288,13 @@ function buildTaskBindingState(task: TaskRow | null | undefined): TaskBindingSta
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
|
function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
|
||||||
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
|
return Boolean(task && getTaskPrimaryClaimTokenId(task))
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRecord(value: unknown): JsonRecord {
|
function normalizeRecord(value: unknown): JsonRecord {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskCode(value: unknown): string {
|
|
||||||
const text = String(value || '').trim()
|
|
||||||
if (!text) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (text.length <= 8) {
|
|
||||||
return `${text.slice(0, 2)}****${text.slice(-2)}`
|
|
||||||
}
|
|
||||||
return `${text.slice(0, 4)}****${text.slice(-4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTaskRetryCount(task: TaskRow | null | undefined): number {
|
function getTaskRetryCount(task: TaskRow | null | undefined): number {
|
||||||
return Number(task?.attempt_count || 0)
|
return Number(task?.attempt_count || 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
|
|
||||||
import { resolveWebhookVisibilityLevel } from './admin-webhook-read-helpers.js'
|
|
||||||
|
|
||||||
test('resolveWebhookVisibilityLevel marks ignored process errors as ignored', () => {
|
|
||||||
assert.equal(resolveWebhookVisibilityLevel('ignored_unconfigured_product'), 'ignored')
|
|
||||||
assert.equal(resolveWebhookVisibilityLevel('ignored_duplicate_event'), 'ignored')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('resolveWebhookVisibilityLevel keeps normal and failed events as important', () => {
|
|
||||||
assert.equal(resolveWebhookVisibilityLevel(''), 'important')
|
|
||||||
assert.equal(resolveWebhookVisibilityLevel('invalid_signature'), 'important')
|
|
||||||
})
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
|
||||||
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
|
|
||||||
import { safeParseJson } from './admin-query-utils.js'
|
|
||||||
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
|
|
||||||
|
|
||||||
import type { AdminWebhookEventListItem } from '../../types/admin-read-models.js'
|
|
||||||
import type { WebhookEventRow } from '../../types/repository-rows.js'
|
|
||||||
|
|
||||||
type JsonRecord = Record<string, any>
|
|
||||||
|
|
||||||
type MapAdminWebhookEventOptions = {
|
|
||||||
includeRaw?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mapAdminWebhookEvent(
|
|
||||||
item: WebhookEventRow,
|
|
||||||
{ includeRaw = false }: MapAdminWebhookEventOptions = {},
|
|
||||||
): Promise<AdminWebhookEventListItem> {
|
|
||||||
const headers = normalizeRecord(safeParseJson(item.headers_json))
|
|
||||||
const query = normalizeRecord(safeParseJson(item.query_json))
|
|
||||||
const body = normalizeRecord(safeParseJson(item.body_json))
|
|
||||||
const payload = extractWebhookPayload(body)
|
|
||||||
const itemSources = extractWebhookItemSources(payload)
|
|
||||||
const rawAmount = pickFirstNonEmpty([
|
|
||||||
payload.total_fee,
|
|
||||||
payload.totalFee,
|
|
||||||
payload.TotalFee,
|
|
||||||
payload.pay_fee,
|
|
||||||
payload.payFee,
|
|
||||||
payload.Payment,
|
|
||||||
payload.payment,
|
|
||||||
payload.buy_amount,
|
|
||||||
payload.buyAmount,
|
|
||||||
])
|
|
||||||
const totalAmountFen = parseAmountToFen(rawAmount)
|
|
||||||
const taskCount = item.related_order_id ? (await listTasksByOrderId(item.related_order_id)).length : 0
|
|
||||||
const shopId = item.shop_id || pickFirstNonEmpty([
|
|
||||||
payload.PlatformUserId,
|
|
||||||
payload.platformUserId,
|
|
||||||
payload.platform_user_id,
|
|
||||||
payload.seller_id,
|
|
||||||
payload.sellerId,
|
|
||||||
payload.SellerId,
|
|
||||||
payload.seller_nick,
|
|
||||||
payload.sellerNick,
|
|
||||||
payload.SellerNick,
|
|
||||||
])
|
|
||||||
|
|
||||||
const mapped = {
|
|
||||||
eventId: item.id,
|
|
||||||
provider: item.provider || '',
|
|
||||||
platform: item.platform,
|
|
||||||
platformRaw: pickFirstNonEmpty([
|
|
||||||
query.fromPlatform,
|
|
||||||
query.from_platform,
|
|
||||||
body.fromPlatform,
|
|
||||||
body.from_platform,
|
|
||||||
payload.fromPlatform,
|
|
||||||
payload.FromPlatform,
|
|
||||||
payload.platform,
|
|
||||||
payload.Platform,
|
|
||||||
]),
|
|
||||||
shopId,
|
|
||||||
shopName: resolveDisplayShopName(
|
|
||||||
item.provider,
|
|
||||||
shopId,
|
|
||||||
item.shop_name || pickFirstNonEmpty([
|
|
||||||
payload.shopName,
|
|
||||||
payload.shop_name,
|
|
||||||
payload.ShopName,
|
|
||||||
payload.seller_nick,
|
|
||||||
payload.sellerNick,
|
|
||||||
payload.SellerNick,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
eventType: item.event_type,
|
|
||||||
eventKey: item.event_key,
|
|
||||||
signatureValid: Boolean(item.signature_valid),
|
|
||||||
processed: Boolean(item.processed),
|
|
||||||
processError: item.process_error,
|
|
||||||
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
|
|
||||||
relatedOrderId: item.related_order_id,
|
|
||||||
createdAt: item.created_at,
|
|
||||||
platformOrderId: resolveWebhookPlatformOrderId(item, payload),
|
|
||||||
buyerId: pickFirstNonEmpty([
|
|
||||||
payload.buyer_id,
|
|
||||||
payload.buyerId,
|
|
||||||
payload.BuyerId,
|
|
||||||
payload.openid,
|
|
||||||
payload.BuyerOpenUid,
|
|
||||||
payload.buyer_open_uid,
|
|
||||||
payload.buyerOpenUid,
|
|
||||||
]),
|
|
||||||
buyerName: pickFirstNonEmpty([
|
|
||||||
payload.buyer_name,
|
|
||||||
payload.buyerName,
|
|
||||||
payload.BuyerName,
|
|
||||||
payload.nick,
|
|
||||||
payload.BuyerNick,
|
|
||||||
payload.buyer_nick,
|
|
||||||
payload.buyerNick,
|
|
||||||
]),
|
|
||||||
totalAmount: rawAmount ? formatFenToAmount(totalAmountFen) : '',
|
|
||||||
totalAmountFen,
|
|
||||||
currency: pickFirstNonEmpty([payload.currency, payload.Currency, 'CNY']) || 'CNY',
|
|
||||||
itemCount: itemSources.length,
|
|
||||||
taskCount,
|
|
||||||
messageText: pickFirstNonEmpty([
|
|
||||||
payload.MsgTypeDes,
|
|
||||||
payload.msgTypeDes,
|
|
||||||
payload.msg_type_desc,
|
|
||||||
payload.message,
|
|
||||||
payload.msg,
|
|
||||||
]),
|
|
||||||
requestTimestamp: pickFirstNonEmpty([query.timestamp]),
|
|
||||||
requestSign: pickFirstNonEmpty([query.sign]),
|
|
||||||
aopic: pickFirstNonEmpty([query.aopic]),
|
|
||||||
sourceHost: pickFirstNonEmpty([headers['x-forwarded-host'], headers.host]),
|
|
||||||
sourceIp: pickFirstNonEmpty([headers['x-forwarded-for'], headers['x-real-ip']]),
|
|
||||||
userAgent: pickFirstNonEmpty([headers['user-agent'], headers['User-Agent']]),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!includeRaw) {
|
|
||||||
return mapped
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...mapped,
|
|
||||||
headers,
|
|
||||||
query,
|
|
||||||
body,
|
|
||||||
payload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractWebhookPayload(body: JsonRecord): JsonRecord {
|
|
||||||
const candidates = [
|
|
||||||
body.data,
|
|
||||||
body.Data,
|
|
||||||
body.payload,
|
|
||||||
body.Payload,
|
|
||||||
body.message,
|
|
||||||
body.Message,
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
|
||||||
return normalizeRecord(candidate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string {
|
|
||||||
const fromPayload = pickFirstNonEmpty([
|
|
||||||
payload.biz_order_id,
|
|
||||||
payload.bizOrderId,
|
|
||||||
payload.BizOrderId,
|
|
||||||
payload.tid,
|
|
||||||
payload.Tid,
|
|
||||||
payload.order_id,
|
|
||||||
payload.orderId,
|
|
||||||
payload.OrderId,
|
|
||||||
payload.oid,
|
|
||||||
payload.Oid,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (fromPayload) {
|
|
||||||
return fromPayload
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean)
|
|
||||||
return parts.find((part) => /^\d{6,}$/.test(part)) || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
|
|
||||||
const normalizedPayload = normalizeRecord(payload)
|
|
||||||
const candidates = [
|
|
||||||
normalizedPayload.orders,
|
|
||||||
normalizedPayload.Orders,
|
|
||||||
normalizedPayload.items,
|
|
||||||
normalizedPayload.Items,
|
|
||||||
normalizedPayload.order_items,
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (Array.isArray(candidate) && candidate.length > 0) {
|
|
||||||
return candidate.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
pickFirstNonEmpty([
|
|
||||||
normalizedPayload.item_id,
|
|
||||||
normalizedPayload.itemId,
|
|
||||||
normalizedPayload.ItemId,
|
|
||||||
normalizedPayload.num_iid,
|
|
||||||
normalizedPayload.numIid,
|
|
||||||
normalizedPayload.NumIid,
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return [normalizedPayload]
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWebhookVisibilityLevel(processError: unknown): string {
|
|
||||||
const normalized = String(processError || '').trim()
|
|
||||||
|
|
||||||
if (normalized.startsWith('ignored_')) {
|
|
||||||
return 'ignored'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'important'
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRecord(value: unknown): JsonRecord {
|
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFirstNonEmpty(values: unknown[]): string {
|
|
||||||
for (const value of values) {
|
|
||||||
if (typeof value === 'string' && value.trim()) {
|
|
||||||
return value.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import {
|
|
||||||
createInventoryItems,
|
|
||||||
invalidateInventoryItem,
|
|
||||||
listInventoryItems,
|
|
||||||
releaseReservedInventoryItem,
|
|
||||||
} from '../../../repositories/inventory-repo.js'
|
|
||||||
import { createHttpError } from '../../../utils/http.js'
|
|
||||||
import { nowIso } from '../../../utils/time.js'
|
|
||||||
import {
|
|
||||||
getRequiredInventoryItem,
|
|
||||||
mapAdminInventoryListItem,
|
|
||||||
} from '../admin-inventory-read-helpers.js'
|
|
||||||
|
|
||||||
import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js'
|
|
||||||
import type {
|
|
||||||
AdminInventoryCreateInput,
|
|
||||||
AdminInventoryImportInput,
|
|
||||||
AdminInventoryImportRowInput,
|
|
||||||
AdminInventoryInvalidateInput,
|
|
||||||
} from '../../../types/admin-write-inputs.js'
|
|
||||||
import type {
|
|
||||||
AdminInventoryImportResponse,
|
|
||||||
AdminInventoryMutationResponse,
|
|
||||||
} from '../../../types/admin-write-models.js'
|
|
||||||
import type { InventoryItemRow } from '../../../types/repository-rows.js'
|
|
||||||
|
|
||||||
type NormalizedInventoryImportRow = Required<Pick<
|
|
||||||
AdminInventoryImportRowInput,
|
|
||||||
'skuCode' | 'displayValue' | 'batchNo' | 'credentialType' | 'inventoryGroupCode'
|
|
||||||
>>
|
|
||||||
|
|
||||||
export async function createAdminInventoryItem(
|
|
||||||
payload: AdminInventoryCreateInput = {},
|
|
||||||
): Promise<AdminInventoryMutationResponse> {
|
|
||||||
const skuCode = String(payload.skuCode || '').trim()
|
|
||||||
const displayValue = String(payload.displayValue || '').trim()
|
|
||||||
const batchNo = String(payload.batchNo || '').trim()
|
|
||||||
const credentialType = String(payload.credentialType || 'claim_code').trim() || 'claim_code'
|
|
||||||
const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim()
|
|
||||||
|
|
||||||
if (!skuCode || !displayValue) {
|
|
||||||
throw createHttpError('缺少 skuCode 或 displayValue', {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'admin_inventory_create_invalid',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = nowIso()
|
|
||||||
const created = await createInventoryItems([
|
|
||||||
{
|
|
||||||
skuCode,
|
|
||||||
displayValue,
|
|
||||||
batchNo,
|
|
||||||
credentialType,
|
|
||||||
inventoryGroupCode,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
if (created === 0) {
|
|
||||||
throw createHttpError('库存凭据已存在,不能重复新增', {
|
|
||||||
statusCode: 409,
|
|
||||||
errorCode: 'admin_inventory_duplicate',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items } = await listInventoryItems({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
skuCode,
|
|
||||||
inventoryGroupCode,
|
|
||||||
})
|
|
||||||
const createdItem = items.find((item) => item.display_value === displayValue && item.inventory_group_code === inventoryGroupCode) || null
|
|
||||||
|
|
||||||
return {
|
|
||||||
inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function importAdminInventoryItems(
|
|
||||||
payload: AdminInventoryImportInput = {},
|
|
||||||
): Promise<AdminInventoryImportResponse> {
|
|
||||||
const rows = normalizeInventoryImportRows(payload)
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
throw createHttpError('没有可导入的库存凭据数据', {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'admin_inventory_import_empty',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = nowIso()
|
|
||||||
const normalizedRows = rows.map((row) => ({
|
|
||||||
skuCode: row.skuCode,
|
|
||||||
batchNo: row.batchNo,
|
|
||||||
displayValue: row.displayValue,
|
|
||||||
credentialType: row.credentialType,
|
|
||||||
inventoryGroupCode: row.inventoryGroupCode,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const created = await createInventoryItems(normalizedRows)
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: normalizedRows.length,
|
|
||||||
created,
|
|
||||||
duplicated: normalizedRows.length - created,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function releaseAdminInventoryItem(
|
|
||||||
inventoryItemId: AdminEntityIdInput,
|
|
||||||
): Promise<AdminInventoryMutationResponse> {
|
|
||||||
const inventoryItem = await getRequiredInventoryItem(inventoryItemId)
|
|
||||||
|
|
||||||
if (inventoryItem.status !== 'reserved') {
|
|
||||||
throw createHttpError('当前库存项不是预占状态,不能释放', {
|
|
||||||
statusCode: 409,
|
|
||||||
errorCode: 'admin_inventory_release_not_allowed',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await releaseReservedInventoryItem(inventoryItem.id, nowIso())
|
|
||||||
|
|
||||||
return {
|
|
||||||
inventoryItem: await mapAdminInventoryListItem(updated as InventoryItemRow),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function invalidateAdminInventoryItem(
|
|
||||||
inventoryItemId: AdminEntityIdInput,
|
|
||||||
payload: AdminInventoryInvalidateInput = {},
|
|
||||||
): Promise<AdminInventoryMutationResponse> {
|
|
||||||
const inventoryItem = await getRequiredInventoryItem(inventoryItemId)
|
|
||||||
|
|
||||||
if (inventoryItem.status !== 'available') {
|
|
||||||
throw createHttpError('只有可用库存项才能作废,请先释放预占', {
|
|
||||||
statusCode: 409,
|
|
||||||
errorCode: 'admin_inventory_invalidate_not_allowed',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const reason = String(payload.reason || '').trim() || '后台手动作废'
|
|
||||||
const updated = await invalidateInventoryItem(inventoryItem.id, reason, nowIso())
|
|
||||||
|
|
||||||
return {
|
|
||||||
inventoryItem: await mapAdminInventoryListItem(updated as InventoryItemRow),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInventoryImportRows(payload: AdminInventoryImportInput): NormalizedInventoryImportRow[] {
|
|
||||||
const rows: NormalizedInventoryImportRow[] = []
|
|
||||||
const directRows = Array.isArray(payload.rows) ? payload.rows : []
|
|
||||||
const bulkCodes = Array.isArray(payload.codes) ? payload.codes : []
|
|
||||||
|
|
||||||
if (directRows.length > 0) {
|
|
||||||
for (const row of directRows) {
|
|
||||||
const skuCode = String(row?.skuCode || '').trim()
|
|
||||||
const displayValue = String(row?.displayValue || '').trim()
|
|
||||||
const batchNo = String(row?.batchNo || '').trim()
|
|
||||||
const credentialType = String(row?.credentialType || payload.credentialType || 'claim_code').trim() || 'claim_code'
|
|
||||||
const inventoryGroupCode = String(row?.inventoryGroupCode || payload.inventoryGroupCode || '').trim()
|
|
||||||
|
|
||||||
if (!skuCode || !displayValue) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bulkCodes.length > 0) {
|
|
||||||
const skuCode = String(payload.skuCode || '').trim()
|
|
||||||
const batchNo = String(payload.batchNo || '').trim()
|
|
||||||
const credentialType = String(payload.credentialType || 'claim_code').trim() || 'claim_code'
|
|
||||||
const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim()
|
|
||||||
|
|
||||||
if (!skuCode) {
|
|
||||||
throw createHttpError('批量导入时缺少 skuCode', {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'admin_inventory_import_missing_sku',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const rawValue of bulkCodes) {
|
|
||||||
const displayValue = String(rawValue || '').trim()
|
|
||||||
if (!displayValue) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dedupeRows(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dedupeRows(rows: NormalizedInventoryImportRow[]): NormalizedInventoryImportRow[] {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const output: NormalizedInventoryImportRow[] = []
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const key = `${row.skuCode}::${row.credentialType || 'claim_code'}::${row.displayValue}`
|
|
||||||
if (seen.has(key)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen.add(key)
|
|
||||||
output.push(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
@@ -198,7 +198,6 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(
|
|||||||
|
|
||||||
const updatedTask = await updateTask(task.id, {
|
const updatedTask = await updateTask(task.id, {
|
||||||
task_status: 'waiting_binding',
|
task_status: 'waiting_binding',
|
||||||
inventory_status: 'not_required',
|
|
||||||
user_action_status: 'pending',
|
user_action_status: 'pending',
|
||||||
claim_token: claimLinkState.token || task.claim_token || '',
|
claim_token: claimLinkState.token || task.claim_token || '',
|
||||||
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
maskCode as maskCodeValue,
|
maskCode as maskCodeValue,
|
||||||
maskPhone as maskPhoneValue,
|
maskPhone as maskPhoneValue,
|
||||||
} from '../../../utils/masking.js'
|
} from '../../../utils/masking.js'
|
||||||
import { parseTaskContext } from '../admin-read-shared-helpers.js'
|
|
||||||
|
|
||||||
import type { TaskRow } from '../../../types/repository-rows.js'
|
import type { TaskRow } from '../../../types/repository-rows.js'
|
||||||
|
|
||||||
@@ -26,12 +25,6 @@ export function maskCode(value: unknown): string {
|
|||||||
return maskCodeValue(value)
|
return maskCodeValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTaskInventoryGroupCodes(task: TaskRow): string[] | null {
|
|
||||||
const taskContext = parseTaskContext(task)
|
|
||||||
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
|
|
||||||
return inventoryGroupCode ? [inventoryGroupCode] : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRecoverableTaskSessionCloseError(error: ErrorLike | null | undefined): boolean {
|
export function isRecoverableTaskSessionCloseError(error: ErrorLike | null | undefined): boolean {
|
||||||
const errorCode = String(error?.errorCode || error?.code || '').trim()
|
const errorCode = String(error?.errorCode || error?.code || '').trim()
|
||||||
return errorCode === 'session_not_found' || errorCode === 'session_closed'
|
return errorCode === 'session_not_found' || errorCode === 'session_closed'
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||||
import { releaseReservedInventoryItem } from '../../repositories/inventory-repo.js'
|
|
||||||
import { getOrderItemById } from '../../repositories/order-item-repo.js'
|
import { getOrderItemById } from '../../repositories/order-item-repo.js'
|
||||||
import { getOrderById } from '../../repositories/order-repo.js'
|
import { getOrderById } from '../../repositories/order-repo.js'
|
||||||
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
|
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
|
||||||
@@ -228,15 +227,10 @@ async function expireClaimContext(claimToken, task) {
|
|||||||
let nextTask = task
|
let nextTask = task
|
||||||
|
|
||||||
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
|
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
|
||||||
if (task.primary_inventory_item_id) {
|
|
||||||
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTask = await updateTask(task.id, {
|
nextTask = await updateTask(task.id, {
|
||||||
task_status: 'expired',
|
task_status: 'expired',
|
||||||
inventory_status: 'pending',
|
|
||||||
user_action_status: 'expired',
|
user_action_status: 'expired',
|
||||||
last_error: '领取链接已过期,预占库存项已释放',
|
last_error: '领取链接已过期',
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ export async function prepareKuaishouCloudFulfillmentTask(
|
|||||||
|
|
||||||
const updatedTask = await updateTask(task.id, {
|
const updatedTask = await updateTask(task.id, {
|
||||||
task_status: "waiting_binding",
|
task_status: "waiting_binding",
|
||||||
inventory_status: "not_required",
|
|
||||||
user_action_status: "pending_claim",
|
user_action_status: "pending_claim",
|
||||||
claim_token: claimLinkState.token || task.claim_token || "",
|
claim_token: claimLinkState.token || task.claim_token || "",
|
||||||
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
||||||
|
|||||||
@@ -221,14 +221,14 @@ export function notifyClaimRedeemNeedsAttention({
|
|||||||
const orderRecord = toRecord(order)
|
const orderRecord = toRecord(order)
|
||||||
|
|
||||||
return notifyInternalSafely({
|
return notifyInternalSafely({
|
||||||
title: status === 'waiting_inventory' ? '库存不足,任务等待补货' : '兑换失败,任务需关注',
|
title: '兑换失败,任务需关注',
|
||||||
body: [
|
body: [
|
||||||
formatTaskLine(taskRecord),
|
formatTaskLine(taskRecord),
|
||||||
`订单:${String(orderRecord.platform_order_id || taskRecord.platform_order_id || '').trim() || '-'}`,
|
`订单:${String(orderRecord.platform_order_id || taskRecord.platform_order_id || '').trim() || '-'}`,
|
||||||
`状态:${String(status || taskRecord.task_status || '').trim() || '-'}`,
|
`状态:${String(status || taskRecord.task_status || '').trim() || '-'}`,
|
||||||
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
|
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
category: status === 'waiting_inventory' ? 'task_waiting_inventory' : 'claim_redeem_failed',
|
category: 'claim_redeem_failed',
|
||||||
cooldownKey: ['claim_redeem_needs_attention', taskRecord.id || '', status].join(':'),
|
cooldownKey: ['claim_redeem_needs_attention', taskRecord.id || '', status].join(':'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { upsertOrderFromSource } from '../order/order-service.js'
|
import { upsertOrderFromSource } from '../order/order-service.js'
|
||||||
import { logWebhook } from '../../utils/logger.js'
|
import { logIntegration } from '../../utils/logger.js'
|
||||||
import { parseAmountToFen } from '../../utils/money.js'
|
import { parseAmountToFen } from '../../utils/money.js'
|
||||||
import {
|
import {
|
||||||
buildOpen91SourceEvent,
|
buildOpen91SourceEvent,
|
||||||
@@ -45,7 +45,7 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
|
|||||||
sourceLabel: 'open91',
|
sourceLabel: 'open91',
|
||||||
})
|
})
|
||||||
|
|
||||||
logWebhook('[open-91/create]', '91卡券下单已处理', {
|
logIntegration('[open-91/create]', '91卡券下单已处理', {
|
||||||
requestId,
|
requestId,
|
||||||
orderNo: normalized.orderNo,
|
orderNo: normalized.orderNo,
|
||||||
productNo: normalized.productNo,
|
productNo: normalized.productNo,
|
||||||
@@ -58,7 +58,7 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
|
|||||||
|
|
||||||
if (result.ignored) {
|
if (result.ignored) {
|
||||||
const pending = await upsertOpen91PendingOrder(normalized, config)
|
const pending = await upsertOpen91PendingOrder(normalized, config)
|
||||||
logWebhook('[open-91/create]', '91卡券订单已进入待补全队列', {
|
logIntegration('[open-91/create]', '91卡券订单已进入待补全队列', {
|
||||||
requestId,
|
requestId,
|
||||||
orderNo: normalized.orderNo,
|
orderNo: normalized.orderNo,
|
||||||
productNo: normalized.productNo,
|
productNo: normalized.productNo,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
|
|||||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||||
import { buildClaimUrl } from '../claim/claim-service.js'
|
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||||
import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud-task-service.js'
|
import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud-task-service.js'
|
||||||
import { logWebhook } from '../../utils/logger.js'
|
import { logIntegration } from '../../utils/logger.js'
|
||||||
import {
|
import {
|
||||||
OPEN_91_MANUAL_FAILED_STATUS,
|
OPEN_91_MANUAL_FAILED_STATUS,
|
||||||
OPEN_91_PENDING_CONFIG_STATUS,
|
OPEN_91_PENDING_CONFIG_STATUS,
|
||||||
@@ -111,7 +111,7 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) {
|
|||||||
readyTaskIds,
|
readyTaskIds,
|
||||||
})
|
})
|
||||||
|
|
||||||
logWebhook('[open-91/query]', '91卡券订单查询已处理', {
|
logIntegration('[open-91/query]', '91卡券订单查询已处理', {
|
||||||
requestId,
|
requestId,
|
||||||
orderNo: normalized.orderNo,
|
orderNo: normalized.orderNo,
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
@@ -154,7 +154,7 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) {
|
|||||||
cards: encryptedCards,
|
cards: encryptedCards,
|
||||||
}
|
}
|
||||||
|
|
||||||
logWebhook('[open-91/query]', '91卡券订单查询返回 cards 已生成', {
|
logIntegration('[open-91/query]', '91卡券订单查询返回 cards 已生成', {
|
||||||
requestId,
|
requestId,
|
||||||
orderNo: normalized.orderNo,
|
orderNo: normalized.orderNo,
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
|
|||||||
@@ -31,58 +31,12 @@ function createTaskFixture(patch = {}) {
|
|||||||
requires_claim: true,
|
requires_claim: true,
|
||||||
claim_token: '',
|
claim_token: '',
|
||||||
primary_claim_token_id: null,
|
primary_claim_token_id: null,
|
||||||
primary_inventory_item_id: null,
|
|
||||||
last_error: '',
|
last_error: '',
|
||||||
context_json: JSON.stringify({
|
context_json: '{}',
|
||||||
inventorySkuCode: 'dnf-cdk-a',
|
|
||||||
primaryRequirement: {
|
|
||||||
roleKey: 'primary_code',
|
|
||||||
credentialType: 'claim_code',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...patch,
|
...patch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('syncDeliveryTasksForOrderWithDeps moves paid claim task to waiting inventory when reservation fails', async () => {
|
|
||||||
const updates = []
|
|
||||||
const reserveCalls = []
|
|
||||||
|
|
||||||
const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, orderItems, {
|
|
||||||
listTasksByOrderId: async () => [createTaskFixture()],
|
|
||||||
reserveInventoryForTask: async (payload) => {
|
|
||||||
reserveCalls.push(payload)
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
updateTask: async (taskId, patch) => {
|
|
||||||
updates.push({ taskId, patch })
|
|
||||||
return { id: taskId, ...patch }
|
|
||||||
},
|
|
||||||
nowIso: () => '2026-04-14T12:00:00.000Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.deepEqual(reserveCalls, [
|
|
||||||
{
|
|
||||||
skuCode: 'dnf-cdk-a',
|
|
||||||
taskId: 30,
|
|
||||||
credentialType: 'claim_code',
|
|
||||||
roleKey: 'primary_code',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
assert.deepEqual(updates, [
|
|
||||||
{
|
|
||||||
taskId: 30,
|
|
||||||
patch: {
|
|
||||||
task_status: 'waiting_inventory',
|
|
||||||
inventory_status: 'pending',
|
|
||||||
last_error: '库存不足,等待可用库存凭据',
|
|
||||||
updated_at: '2026-04-14T12:00:00.000Z',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
assert.equal(result[0]?.task_status, 'waiting_inventory')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('syncDeliveryTasksForOrderWithDeps sends manual dispatch tasks to manual review', async () => {
|
test('syncDeliveryTasksForOrderWithDeps sends manual dispatch tasks to manual review', async () => {
|
||||||
const notifications = []
|
const notifications = []
|
||||||
const updates = []
|
const updates = []
|
||||||
@@ -150,7 +104,6 @@ test('syncDeliveryTasksForOrderWithDeps prepares kuaishou cloud task with claim
|
|||||||
taskId: 30,
|
taskId: 30,
|
||||||
patch: {
|
patch: {
|
||||||
task_status: 'pending_binding_prepare',
|
task_status: 'pending_binding_prepare',
|
||||||
inventory_status: 'not_required',
|
|
||||||
claim_token: 'claim-token',
|
claim_token: 'claim-token',
|
||||||
claim_expires_at: '2026-04-15T12:00:00.000Z',
|
claim_expires_at: '2026-04-15T12:00:00.000Z',
|
||||||
user_action_status: 'pending_claim',
|
user_action_status: 'pending_claim',
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
|
import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
|
||||||
import {
|
import {
|
||||||
listFulfillmentProfileRequirements,
|
|
||||||
resolveFulfillmentBinding,
|
resolveFulfillmentBinding,
|
||||||
} from '../../repositories/fulfillment-profile-repo.js'
|
} from '../../repositories/fulfillment-profile-repo.js'
|
||||||
import { reserveInventoryForTask } from './inventory-service.js'
|
|
||||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||||
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js'
|
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.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 type { InventoryItemRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js'
|
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>
|
type JsonObject = Record<string, unknown>
|
||||||
|
|
||||||
@@ -31,15 +29,6 @@ type FulfillmentBindingLike = {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
type FulfillmentRequirementLike = {
|
|
||||||
role_key?: string
|
|
||||||
roleKey?: string
|
|
||||||
credential_type?: string
|
|
||||||
credentialType?: string
|
|
||||||
is_required?: boolean
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeliveryTaskRow = TaskRow & {
|
type DeliveryTaskRow = TaskRow & {
|
||||||
skuCode?: string
|
skuCode?: string
|
||||||
skuName?: string
|
skuName?: string
|
||||||
@@ -49,21 +38,12 @@ type DeliveryTaskDeps = {
|
|||||||
createTask?: typeof createTask
|
createTask?: typeof createTask
|
||||||
listTasksByOrderId?: typeof listTasksByOrderId
|
listTasksByOrderId?: typeof listTasksByOrderId
|
||||||
updateTask?: typeof updateTask
|
updateTask?: typeof updateTask
|
||||||
listFulfillmentProfileRequirements?: (
|
|
||||||
profileId: number | string,
|
|
||||||
) => Promise<FulfillmentRequirementLike[]>
|
|
||||||
resolveFulfillmentBinding?: (input: {
|
resolveFulfillmentBinding?: (input: {
|
||||||
skuCode: string
|
skuCode: string
|
||||||
provider?: string
|
provider?: string
|
||||||
platform?: string
|
platform?: string
|
||||||
shopId?: string
|
shopId?: string
|
||||||
}) => Promise<FulfillmentBindingLike | null>
|
}) => Promise<FulfillmentBindingLike | null>
|
||||||
reserveInventoryForTask?: (input: {
|
|
||||||
skuCode: string
|
|
||||||
taskId: number | string
|
|
||||||
credentialType?: string
|
|
||||||
roleKey?: string
|
|
||||||
}) => Promise<InventoryItemRow | null>
|
|
||||||
createTaskClaimToken?: (taskId: number | string) => Promise<ClaimTokenLike>
|
createTaskClaimToken?: (taskId: number | string) => Promise<ClaimTokenLike>
|
||||||
notifyTaskAutoManualReview?: (payload: {
|
notifyTaskAutoManualReview?: (payload: {
|
||||||
task: unknown
|
task: unknown
|
||||||
@@ -77,18 +57,12 @@ type DeliveryTaskDeps = {
|
|||||||
type RuntimeDeliveryTaskDeps = Required<Pick<
|
type RuntimeDeliveryTaskDeps = Required<Pick<
|
||||||
DeliveryTaskDeps,
|
DeliveryTaskDeps,
|
||||||
'updateTask'
|
'updateTask'
|
||||||
| 'reserveInventoryForTask'
|
|
||||||
| 'createTaskClaimToken'
|
| 'createTaskClaimToken'
|
||||||
| 'notifyTaskAutoManualReview'
|
| 'notifyTaskAutoManualReview'
|
||||||
| 'nowIso'
|
| 'nowIso'
|
||||||
>>
|
>>
|
||||||
|
|
||||||
type TaskContext = {
|
type TaskContext = {
|
||||||
inventorySkuCode?: string
|
|
||||||
primaryRequirement?: {
|
|
||||||
roleKey?: string
|
|
||||||
credentialType?: string
|
|
||||||
} | null
|
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,9 +82,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
createTask: createDeliveryTask = createTask,
|
createTask: createDeliveryTask = createTask,
|
||||||
listTasksByOrderId: listTasks = listTasksByOrderId,
|
listTasksByOrderId: listTasks = listTasksByOrderId,
|
||||||
updateTask: updateDeliveryTask = updateTask,
|
updateTask: updateDeliveryTask = updateTask,
|
||||||
listFulfillmentProfileRequirements: listProfileRequirements = listFulfillmentProfileRequirements,
|
|
||||||
resolveFulfillmentBinding: resolveBinding = resolveFulfillmentBinding,
|
resolveFulfillmentBinding: resolveBinding = resolveFulfillmentBinding,
|
||||||
reserveInventoryForTask: reserveInventory = reserveInventoryForTask,
|
|
||||||
createTaskClaimToken: createClaimToken = createTaskClaimToken,
|
createTaskClaimToken: createClaimToken = createTaskClaimToken,
|
||||||
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
|
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
|
||||||
nowIso: getNowIso = nowIso,
|
nowIso: getNowIso = nowIso,
|
||||||
@@ -119,7 +91,6 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
|
|
||||||
const runtimeDeps: RuntimeDeliveryTaskDeps = {
|
const runtimeDeps: RuntimeDeliveryTaskDeps = {
|
||||||
updateTask: updateDeliveryTask,
|
updateTask: updateDeliveryTask,
|
||||||
reserveInventoryForTask: reserveInventory,
|
|
||||||
createTaskClaimToken: createClaimToken,
|
createTaskClaimToken: createClaimToken,
|
||||||
notifyTaskAutoManualReview: notifyManualReview,
|
notifyTaskAutoManualReview: notifyManualReview,
|
||||||
nowIso: getNowIso,
|
nowIso: getNowIso,
|
||||||
@@ -154,8 +125,6 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const profile = binding
|
const profile = binding
|
||||||
const requirements = await listProfileRequirements(profile.profile_id || profile.id)
|
|
||||||
const primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null
|
|
||||||
const quantity = Math.max(1, Number(item.quantity || 1))
|
const quantity = Math.max(1, Number(item.quantity || 1))
|
||||||
const fulfillmentConfig = parseJsonObject(profile.config_json)
|
const fulfillmentConfig = parseJsonObject(profile.config_json)
|
||||||
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
|
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
|
||||||
@@ -181,7 +150,6 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
profileId: Number(profile.profile_id || profile.id),
|
profileId: Number(profile.profile_id || profile.id),
|
||||||
executorKey: String(profile.executor_key || 'manual_dispatch'),
|
executorKey: String(profile.executor_key || 'manual_dispatch'),
|
||||||
taskStatus: initialStatus,
|
taskStatus: initialStatus,
|
||||||
inventoryStatus: profile.requires_claim ? 'pending' : 'not_required',
|
|
||||||
deliveryStatus: 'pending',
|
deliveryStatus: 'pending',
|
||||||
resultCode: '',
|
resultCode: '',
|
||||||
resultMessage: '',
|
resultMessage: '',
|
||||||
@@ -197,13 +165,6 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
profileName: String(profile.profile_name || profile.name || ''),
|
profileName: String(profile.profile_name || profile.name || ''),
|
||||||
skuCode: item.sku_code,
|
skuCode: item.sku_code,
|
||||||
skuName: item.sku_name,
|
skuName: item.sku_name,
|
||||||
inventorySkuCode: item.sku_code,
|
|
||||||
primaryRequirement: primaryRequirement
|
|
||||||
? {
|
|
||||||
roleKey: String(primaryRequirement.role_key || primaryRequirement.roleKey || 'primary_code'),
|
|
||||||
credentialType: String(primaryRequirement.credential_type || primaryRequirement.credentialType || 'claim_code'),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key)
|
kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key)
|
||||||
? {
|
? {
|
||||||
flowType: 'kuaishou_cloud_fulfillment',
|
flowType: 'kuaishou_cloud_fulfillment',
|
||||||
@@ -303,16 +264,12 @@ async function preparePaidTask(
|
|||||||
): Promise<TaskRow | DeliveryTaskRow | null> {
|
): Promise<TaskRow | DeliveryTaskRow | null> {
|
||||||
const {
|
const {
|
||||||
updateTask: updateDeliveryTask = updateTask,
|
updateTask: updateDeliveryTask = updateTask,
|
||||||
reserveInventoryForTask: reserveInventory = reserveInventoryForTask,
|
|
||||||
createTaskClaimToken: createClaimToken = createTaskClaimToken,
|
createTaskClaimToken: createClaimToken = createTaskClaimToken,
|
||||||
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
|
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
|
||||||
nowIso: getNowIso = nowIso,
|
nowIso: getNowIso = nowIso,
|
||||||
} = deps
|
} = deps
|
||||||
|
|
||||||
const now = getNowIso()
|
const now = getNowIso()
|
||||||
const taskContext = parseTaskContext(task)
|
|
||||||
const primaryRequirement = taskContext.primaryRequirement || null
|
|
||||||
const inventorySkuCode = String(taskContext.inventorySkuCode || task.skuCode || '').trim()
|
|
||||||
|
|
||||||
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed'].includes(task.task_status)) {
|
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed'].includes(task.task_status)) {
|
||||||
return task
|
return task
|
||||||
@@ -344,7 +301,6 @@ async function preparePaidTask(
|
|||||||
|
|
||||||
return updateDeliveryTask(task.id, {
|
return updateDeliveryTask(task.id, {
|
||||||
task_status: 'pending_binding_prepare',
|
task_status: 'pending_binding_prepare',
|
||||||
inventory_status: 'not_required',
|
|
||||||
claim_token: claimToken.token,
|
claim_token: claimToken.token,
|
||||||
claim_expires_at: claimToken.expired_at,
|
claim_expires_at: claimToken.expired_at,
|
||||||
user_action_status: 'pending_claim',
|
user_action_status: 'pending_claim',
|
||||||
@@ -357,7 +313,6 @@ async function preparePaidTask(
|
|||||||
const lastError = task.last_error || '当前任务需要人工履约处理'
|
const lastError = task.last_error || '当前任务需要人工履约处理'
|
||||||
const updatedTask = await updateDeliveryTask(task.id, {
|
const updatedTask = await updateDeliveryTask(task.id, {
|
||||||
task_status: 'manual_review',
|
task_status: 'manual_review',
|
||||||
inventory_status: 'not_required',
|
|
||||||
user_action_status: 'not_required',
|
user_action_status: 'not_required',
|
||||||
last_error: lastError,
|
last_error: lastError,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
@@ -370,71 +325,7 @@ async function preparePaidTask(
|
|||||||
return updatedTask
|
return updatedTask
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inventorySkuCode) {
|
return task
|
||||||
const lastError = '未匹配到 SKU,无法为任务分配库存凭据'
|
|
||||||
const updatedTask = await updateDeliveryTask(task.id, {
|
|
||||||
task_status: 'manual_review',
|
|
||||||
last_error: lastError,
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
await notifyManualReview({
|
|
||||||
task: updatedTask || task,
|
|
||||||
reason: lastError,
|
|
||||||
source: 'missing_sku',
|
|
||||||
})
|
|
||||||
return updatedTask
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.primary_inventory_item_id && task.primary_claim_token_id) {
|
|
||||||
return updateDeliveryTask(task.id, {
|
|
||||||
task_status: 'link_generated',
|
|
||||||
last_error: '',
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!primaryRequirement?.credentialType) {
|
|
||||||
const lastError = '履约档案未配置库存要求,无法自动分配库存'
|
|
||||||
const updatedTask = await updateDeliveryTask(task.id, {
|
|
||||||
task_status: 'manual_review',
|
|
||||||
last_error: lastError,
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
await notifyManualReview({
|
|
||||||
task: updatedTask || task,
|
|
||||||
reason: lastError,
|
|
||||||
source: 'missing_inventory_requirement',
|
|
||||||
})
|
|
||||||
return updatedTask
|
|
||||||
}
|
|
||||||
|
|
||||||
const reserved = await reserveInventory({
|
|
||||||
skuCode: inventorySkuCode,
|
|
||||||
taskId: task.id,
|
|
||||||
credentialType: primaryRequirement.credentialType,
|
|
||||||
roleKey: primaryRequirement.roleKey || 'primary_code',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!reserved) {
|
|
||||||
return updateDeliveryTask(task.id, {
|
|
||||||
task_status: 'waiting_inventory',
|
|
||||||
inventory_status: 'pending',
|
|
||||||
last_error: '库存不足,等待可用库存凭据',
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const claimToken = await createClaimToken(task.id)
|
|
||||||
|
|
||||||
return updateDeliveryTask(task.id, {
|
|
||||||
task_status: 'link_generated',
|
|
||||||
inventory_status: 'reserved',
|
|
||||||
claim_token: claimToken.token,
|
|
||||||
claim_expires_at: claimToken.expired_at,
|
|
||||||
user_action_status: 'pending_claim',
|
|
||||||
last_error: '',
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePaidTaskStatus(profile: FulfillmentBindingLike | null | undefined): string {
|
function resolvePaidTaskStatus(profile: FulfillmentBindingLike | null | undefined): string {
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
|
|
||||||
import {
|
|
||||||
releaseReservedInventoryItemsWithDeps,
|
|
||||||
reserveInventoryForTaskWithDeps,
|
|
||||||
} from './inventory-service.js'
|
|
||||||
|
|
||||||
test('reserveInventoryForTaskWithDeps returns null when skuCode is missing', async () => {
|
|
||||||
let findCalled = false
|
|
||||||
|
|
||||||
const result = await reserveInventoryForTaskWithDeps(
|
|
||||||
{ skuCode: '', taskId: 12 },
|
|
||||||
{
|
|
||||||
findFirstAvailableInventoryItemBySkuCode: async () => {
|
|
||||||
findCalled = true
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.equal(result, null)
|
|
||||||
assert.equal(findCalled, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('reserveInventoryForTaskWithDeps reserves first available inventory item with defaults', async () => {
|
|
||||||
const calls = []
|
|
||||||
const result = await reserveInventoryForTaskWithDeps(
|
|
||||||
{ skuCode: 'dnf-cdk-a', taskId: 88 },
|
|
||||||
{
|
|
||||||
findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType, inventoryGroupCodes) => {
|
|
||||||
calls.push(['find', skuCode, credentialType, inventoryGroupCodes])
|
|
||||||
return { id: 321 }
|
|
||||||
},
|
|
||||||
assignReservedInventoryItem: async (inventoryItemId, taskId, updatedAt, roleKey) => {
|
|
||||||
calls.push(['assign', inventoryItemId, taskId, updatedAt, roleKey])
|
|
||||||
return { id: inventoryItemId, reserved_by_task_id: taskId, role_key: roleKey, updated_at: updatedAt }
|
|
||||||
},
|
|
||||||
nowIso: () => '2026-04-14T12:00:00.000Z',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
['find', 'dnf-cdk-a', 'claim_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', 'claim_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(
|
|
||||||
[0, null, 11, 12, '', 13],
|
|
||||||
'2026-04-14T13:00:00.000Z',
|
|
||||||
{
|
|
||||||
releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => {
|
|
||||||
calls.push([inventoryItemId, updatedAt])
|
|
||||||
return inventoryItemId === 12 ? null : { id: inventoryItemId }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
[11, '2026-04-14T13:00:00.000Z'],
|
|
||||||
[12, '2026-04-14T13:00:00.000Z'],
|
|
||||||
[13, '2026-04-14T13:00:00.000Z'],
|
|
||||||
])
|
|
||||||
assert.deepEqual(result, [
|
|
||||||
{ id: 11 },
|
|
||||||
{ id: 13 },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import {
|
|
||||||
assignReservedInventoryItem,
|
|
||||||
findFirstAvailableInventoryItemBySkuCode,
|
|
||||||
releaseReservedInventoryItem,
|
|
||||||
} from '../../repositories/inventory-repo.js'
|
|
||||||
import type { InventoryItemRow } from '../../types/repository-rows.js'
|
|
||||||
import { nowIso } from '../../utils/time.js'
|
|
||||||
|
|
||||||
type InventoryItemId = number | string | null | undefined
|
|
||||||
type InventoryGroupCodesInput = string | string[] | null | undefined
|
|
||||||
|
|
||||||
type ReserveInventoryForTaskInput = {
|
|
||||||
skuCode?: string
|
|
||||||
taskId: number | string
|
|
||||||
credentialType?: string
|
|
||||||
roleKey?: string
|
|
||||||
inventoryGroupCodes?: InventoryGroupCodesInput
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReserveInventoryForTaskDeps = {
|
|
||||||
assignReservedInventoryItem?: (
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
taskId: number | string,
|
|
||||||
updatedAt: string,
|
|
||||||
roleKey?: string,
|
|
||||||
) => Promise<InventoryItemRow | null>
|
|
||||||
findFirstAvailableInventoryItemBySkuCode?: (
|
|
||||||
skuCode: string,
|
|
||||||
credentialType?: string,
|
|
||||||
inventoryGroupCodes?: InventoryGroupCodesInput,
|
|
||||||
) => Promise<InventoryItemRow | null>
|
|
||||||
nowIso?: () => string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReleaseReservedInventoryItemsDeps = {
|
|
||||||
releaseReservedInventoryItem?: (
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
updatedAt: string,
|
|
||||||
) => Promise<InventoryItemRow | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reserveInventoryForTask({
|
|
||||||
skuCode,
|
|
||||||
taskId,
|
|
||||||
credentialType = 'claim_code',
|
|
||||||
roleKey = 'primary_code',
|
|
||||||
inventoryGroupCodes = null,
|
|
||||||
}: ReserveInventoryForTaskInput): Promise<InventoryItemRow | null> {
|
|
||||||
return reserveInventoryForTaskWithDeps(
|
|
||||||
{ skuCode, taskId, credentialType, roleKey, inventoryGroupCodes },
|
|
||||||
{
|
|
||||||
assignReservedInventoryItem,
|
|
||||||
findFirstAvailableInventoryItemBySkuCode,
|
|
||||||
nowIso,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reserveInventoryForTaskWithDeps(
|
|
||||||
{
|
|
||||||
skuCode,
|
|
||||||
taskId,
|
|
||||||
credentialType = 'claim_code',
|
|
||||||
roleKey = 'primary_code',
|
|
||||||
inventoryGroupCodes = null,
|
|
||||||
}: ReserveInventoryForTaskInput,
|
|
||||||
{
|
|
||||||
assignReservedInventoryItem: assignReserved = assignReservedInventoryItem,
|
|
||||||
findFirstAvailableInventoryItemBySkuCode: findAvailable = findFirstAvailableInventoryItemBySkuCode,
|
|
||||||
nowIso: getNowIso = nowIso,
|
|
||||||
}: ReserveInventoryForTaskDeps = {},
|
|
||||||
): Promise<InventoryItemRow | null> {
|
|
||||||
if (!skuCode) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(inventoryGroupCodes) && inventoryGroupCodes.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const available = await findAvailable(skuCode, credentialType, inventoryGroupCodes)
|
|
||||||
|
|
||||||
if (!available) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return assignReserved(available.id, taskId, getNowIso(), roleKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function releaseReservedInventoryItems(
|
|
||||||
inventoryItemIds: InventoryItemId[] = [],
|
|
||||||
updatedAt = nowIso(),
|
|
||||||
): Promise<InventoryItemRow[]> {
|
|
||||||
return releaseReservedInventoryItemsWithDeps(
|
|
||||||
inventoryItemIds,
|
|
||||||
updatedAt,
|
|
||||||
{ releaseReservedInventoryItem },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function releaseReservedInventoryItemsWithDeps(
|
|
||||||
inventoryItemIds: InventoryItemId[] = [],
|
|
||||||
updatedAt = nowIso(),
|
|
||||||
{
|
|
||||||
releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem,
|
|
||||||
}: ReleaseReservedInventoryItemsDeps = {},
|
|
||||||
): Promise<InventoryItemRow[]> {
|
|
||||||
const released: InventoryItemRow[] = []
|
|
||||||
|
|
||||||
for (const inventoryItemId of inventoryItemIds) {
|
|
||||||
if (!inventoryItemId) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await releaseReserved(inventoryItemId, updatedAt)
|
|
||||||
if (item) {
|
|
||||||
released.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return released
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
import { mergeWebhookOrderState } from './order-service.js'
|
import { mergeSourceOrderState } from './order-service.js'
|
||||||
|
|
||||||
test('mergeWebhookOrderState keeps paid state when a late trade_create webhook arrives', () => {
|
test('mergeSourceOrderState keeps paid state when a late created source event arrives', () => {
|
||||||
const merged = mergeWebhookOrderState(
|
const merged = mergeSourceOrderState(
|
||||||
{
|
{
|
||||||
order_status: 'paid',
|
order_status: 'paid',
|
||||||
pay_status: 'paid',
|
pay_status: 'paid',
|
||||||
@@ -22,8 +22,8 @@ test('mergeWebhookOrderState keeps paid state when a late trade_create webhook a
|
|||||||
assert.equal(merged.paidAt, '2026-04-14T10:06:10.000Z')
|
assert.equal(merged.paidAt, '2026-04-14T10:06:10.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('mergeWebhookOrderState upgrades unpaid orders when payment webhook arrives later', () => {
|
test('mergeSourceOrderState upgrades unpaid orders when payment source event arrives later', () => {
|
||||||
const merged = mergeWebhookOrderState(
|
const merged = mergeSourceOrderState(
|
||||||
{
|
{
|
||||||
order_status: 'created',
|
order_status: 'created',
|
||||||
pay_status: 'unpaid',
|
pay_status: 'unpaid',
|
||||||
@@ -41,8 +41,8 @@ test('mergeWebhookOrderState upgrades unpaid orders when payment webhook arrives
|
|||||||
assert.equal(merged.paidAt, '2026-04-14T10:06:12.000Z')
|
assert.equal(merged.paidAt, '2026-04-14T10:06:12.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('mergeWebhookOrderState preserves refunded status over older paid webhook', () => {
|
test('mergeSourceOrderState preserves refunded status over older paid source event', () => {
|
||||||
const merged = mergeWebhookOrderState(
|
const merged = mergeSourceOrderState(
|
||||||
{
|
{
|
||||||
order_status: 'refunded',
|
order_status: 'refunded',
|
||||||
pay_status: 'refunded',
|
pay_status: 'refunded',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
|||||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||||
import { resolveOrderItemForFulfillment } from './product-match-service.js'
|
import { resolveOrderItemForFulfillment } from './product-match-service.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { logWebhook } from '../../utils/logger.js'
|
import { logIntegration } from '../../utils/logger.js'
|
||||||
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js'
|
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js'
|
||||||
|
|
||||||
type SourceOrderItem = {
|
type SourceOrderItem = {
|
||||||
@@ -83,10 +83,6 @@ type OrderStateMergeResult = {
|
|||||||
|
|
||||||
type StatusPriorityMap = Record<string, number>
|
type StatusPriorityMap = Record<string, number>
|
||||||
|
|
||||||
export async function upsertOrderFromWebhook(event: SourceOrderEvent): Promise<UpsertOrderResult> {
|
|
||||||
return upsertOrderFromSource(event, { sourceLabel: 'webhook' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertOrderFromSource(
|
export async function upsertOrderFromSource(
|
||||||
event: SourceOrderEvent,
|
event: SourceOrderEvent,
|
||||||
{ sourceLabel = 'source' }: UpsertOrderSourceOptions = {},
|
{ sourceLabel = 'source' }: UpsertOrderSourceOptions = {},
|
||||||
@@ -105,7 +101,7 @@ export async function upsertOrderFromSource(
|
|||||||
platformOrderId: event.platformOrderId,
|
platformOrderId: event.platformOrderId,
|
||||||
})
|
})
|
||||||
|
|
||||||
logWebhook('[order-service]', `开始处理 ${sourceLabel} 订单 upsert`, {
|
logIntegration('[order-service]', `开始处理 ${sourceLabel} 订单 upsert`, {
|
||||||
provider: event.provider,
|
provider: event.provider,
|
||||||
platform: event.platform,
|
platform: event.platform,
|
||||||
shopId: event.shopId,
|
shopId: event.shopId,
|
||||||
@@ -126,7 +122,7 @@ export async function upsertOrderFromSource(
|
|||||||
const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter((item) => item.isConfigured)
|
const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter((item) => item.isConfigured)
|
||||||
|
|
||||||
if (configuredItems.length === 0) {
|
if (configuredItems.length === 0) {
|
||||||
logWebhook('[order-service]', `${sourceLabel} 订单已忽略:未命中任何已配置履约商品`, {
|
logIntegration('[order-service]', `${sourceLabel} 订单已忽略:未命中任何已配置履约商品`, {
|
||||||
provider: event.provider,
|
provider: event.provider,
|
||||||
platform: event.platform,
|
platform: event.platform,
|
||||||
shopId: event.shopId,
|
shopId: event.shopId,
|
||||||
@@ -161,7 +157,7 @@ export async function upsertOrderFromSource(
|
|||||||
rawPayloadJson: JSON.stringify(event.rawPayload),
|
rawPayloadJson: JSON.stringify(event.rawPayload),
|
||||||
paidAt: event.paidAt,
|
paidAt: event.paidAt,
|
||||||
}
|
}
|
||||||
const mergedPayload = mergeWebhookOrderState(existing, basePayload)
|
const mergedPayload = mergeSourceOrderState(existing, basePayload)
|
||||||
|
|
||||||
const order = existing
|
const order = existing
|
||||||
? await updateOrder(existing.id, {
|
? await updateOrder(existing.id, {
|
||||||
@@ -195,7 +191,7 @@ export async function upsertOrderFromSource(
|
|||||||
|
|
||||||
const tasks = await syncDeliveryTasksForOrder(order, orderItems)
|
const tasks = await syncDeliveryTasksForOrder(order, orderItems)
|
||||||
|
|
||||||
logWebhook('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
|
logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
provider: order.provider,
|
provider: order.provider,
|
||||||
platform: order.platform,
|
platform: order.platform,
|
||||||
@@ -235,7 +231,7 @@ const PAY_STATUS_PRIORITY = {
|
|||||||
refunded: 2,
|
refunded: 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeWebhookOrderState(
|
export function mergeSourceOrderState(
|
||||||
existing: OrderStateInput | null | undefined,
|
existing: OrderStateInput | null | undefined,
|
||||||
incoming: OrderStateInput | null | undefined,
|
incoming: OrderStateInput | null | undefined,
|
||||||
): OrderStateMergeResult {
|
): OrderStateMergeResult {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export type AdminViewerSessionInput = {
|
|||||||
userId?: number | string
|
userId?: number | string
|
||||||
username?: string
|
username?: string
|
||||||
role?: string
|
role?: string
|
||||||
allowedInventoryGroups?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminOrderListQueryInput = {
|
export type AdminOrderListQueryInput = {
|
||||||
@@ -27,34 +26,4 @@ export type AdminTaskListQueryInput = {
|
|||||||
dateTo?: string
|
dateTo?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminInventoryListQueryInput = {
|
|
||||||
page?: number | string
|
|
||||||
pageSize?: number | string
|
|
||||||
skuCode?: string
|
|
||||||
credentialType?: string
|
|
||||||
status?: string
|
|
||||||
batchNo?: string
|
|
||||||
inventoryGroupCode?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminInventorySkuSuggestionQueryInput = {
|
|
||||||
credentialType?: string
|
|
||||||
keyword?: string
|
|
||||||
limit?: number | string
|
|
||||||
inventoryGroupCode?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminWebhookEventListQueryInput = {
|
|
||||||
page?: number | string
|
|
||||||
pageSize?: number | string
|
|
||||||
provider?: string
|
|
||||||
platform?: string
|
|
||||||
platformOrderId?: string
|
|
||||||
processed?: string
|
|
||||||
visibility?: string
|
|
||||||
relatedOrderId?: string
|
|
||||||
dateFrom?: string
|
|
||||||
dateTo?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminEntityIdInput = number | string
|
export type AdminEntityIdInput = number | string
|
||||||
|
|||||||
@@ -63,76 +63,10 @@ export type AdminTaskListItem = {
|
|||||||
lastError: string
|
lastError: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
inventoryDisplayMasked: string
|
|
||||||
inventoryCredentialType: string
|
|
||||||
claimToken: string
|
claimToken: string
|
||||||
screenshotPath: string
|
screenshotPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminInventoryItemListItem = {
|
|
||||||
inventoryItemId: number
|
|
||||||
skuCode: string
|
|
||||||
batchNo: string
|
|
||||||
credentialType: string
|
|
||||||
inventoryGroupCode: string
|
|
||||||
displayValue: string
|
|
||||||
status: string
|
|
||||||
reservedByTaskId: number | null
|
|
||||||
reservedByTaskNo: string
|
|
||||||
platformOrderId: string
|
|
||||||
systemBindingStatus: string
|
|
||||||
userBindingStatus: string
|
|
||||||
invalidReason: string
|
|
||||||
deliveredAt: string | null
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminWebhookEventListItem = {
|
|
||||||
eventId: number
|
|
||||||
provider: string
|
|
||||||
platform: string
|
|
||||||
platformRaw: string
|
|
||||||
shopId: string
|
|
||||||
shopName: string
|
|
||||||
eventType: string
|
|
||||||
eventKey: string
|
|
||||||
signatureValid: boolean
|
|
||||||
processed: boolean
|
|
||||||
processError: string
|
|
||||||
visibilityLevel: string
|
|
||||||
relatedOrderId: number | null
|
|
||||||
platformOrderId: string
|
|
||||||
buyerId: string
|
|
||||||
buyerName: string
|
|
||||||
totalAmount: string
|
|
||||||
totalAmountFen: number
|
|
||||||
currency: string
|
|
||||||
itemCount: number
|
|
||||||
taskCount: number
|
|
||||||
messageText: string
|
|
||||||
requestTimestamp: string
|
|
||||||
requestSign: string
|
|
||||||
aopic: string
|
|
||||||
sourceHost: string
|
|
||||||
sourceIp: string
|
|
||||||
userAgent: string
|
|
||||||
createdAt: string
|
|
||||||
headers?: Record<string, unknown>
|
|
||||||
query?: Record<string, unknown>
|
|
||||||
body?: Record<string, unknown>
|
|
||||||
payload?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminInventorySkuSuggestion = {
|
|
||||||
skuCode: string
|
|
||||||
credentialType: string
|
|
||||||
inventoryGroupCode: string
|
|
||||||
totalCount: number
|
|
||||||
availableCount: number
|
|
||||||
latestUpdatedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminPagination = {
|
export type AdminPagination = {
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@@ -148,17 +82,3 @@ export type AdminTaskListResponse = {
|
|||||||
items: AdminTaskListItem[]
|
items: AdminTaskListItem[]
|
||||||
pagination: AdminPagination
|
pagination: AdminPagination
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminInventoryListResponse = {
|
|
||||||
items: AdminInventoryItemListItem[]
|
|
||||||
pagination: AdminPagination
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminWebhookEventListResponse = {
|
|
||||||
items: AdminWebhookEventListItem[]
|
|
||||||
pagination: AdminPagination
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminInventorySkuSuggestionResponse = {
|
|
||||||
items: AdminInventorySkuSuggestion[]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminInventoryListQueryInput,
|
|
||||||
AdminInventorySkuSuggestionQueryInput,
|
|
||||||
AdminOrderListQueryInput,
|
AdminOrderListQueryInput,
|
||||||
AdminTaskListQueryInput,
|
AdminTaskListQueryInput,
|
||||||
AdminViewerSessionInput,
|
AdminViewerSessionInput,
|
||||||
AdminWebhookEventListQueryInput,
|
|
||||||
} from './admin-read-inputs.js'
|
} from './admin-read-inputs.js'
|
||||||
import type {
|
import type {
|
||||||
AdminCloudtentaclesCatalogQueryInput,
|
AdminCloudtentaclesCatalogQueryInput,
|
||||||
@@ -16,9 +13,6 @@ import type {
|
|||||||
AdminCloudtentaclesTestLoginInput,
|
AdminCloudtentaclesTestLoginInput,
|
||||||
AdminCloudtentaclesValidateSessionInput,
|
AdminCloudtentaclesValidateSessionInput,
|
||||||
AdminCloudtentaclesVirtualNumberInput,
|
AdminCloudtentaclesVirtualNumberInput,
|
||||||
AdminInventoryCreateInput,
|
|
||||||
AdminInventoryImportInput,
|
|
||||||
AdminInventoryInvalidateInput,
|
|
||||||
AdminKuaishouCloudFulfillmentConfigInput,
|
AdminKuaishouCloudFulfillmentConfigInput,
|
||||||
AdminKuaishouEticketConsumeInput,
|
AdminKuaishouEticketConsumeInput,
|
||||||
AdminKuaishouEticketDetailQueryInput,
|
AdminKuaishouEticketDetailQueryInput,
|
||||||
@@ -29,15 +23,11 @@ import type {
|
|||||||
AdminScheduledJobsConfigInput,
|
AdminScheduledJobsConfigInput,
|
||||||
AdminTaskKuaishouCloudDispatchInput,
|
AdminTaskKuaishouCloudDispatchInput,
|
||||||
AdminTaskManualDispatchInput,
|
AdminTaskManualDispatchInput,
|
||||||
AdminUserInventoryGroupsInput,
|
|
||||||
} from './admin-write-inputs.js'
|
} from './admin-write-inputs.js'
|
||||||
|
|
||||||
export type AdminInventoryRouteQuery = AdminInventoryListQueryInput
|
|
||||||
export type AdminInventorySkuSuggestionRouteQuery = AdminInventorySkuSuggestionQueryInput
|
|
||||||
export type AdminOrderRouteQuery = AdminOrderListQueryInput
|
export type AdminOrderRouteQuery = AdminOrderListQueryInput
|
||||||
export type AdminTaskRouteQuery = AdminTaskListQueryInput
|
export type AdminTaskRouteQuery = AdminTaskListQueryInput
|
||||||
export type AdminRouteAdminSession = AdminViewerSessionInput
|
export type AdminRouteAdminSession = AdminViewerSessionInput
|
||||||
export type AdminWebhookEventRouteQuery = AdminWebhookEventListQueryInput
|
|
||||||
|
|
||||||
export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSourceConfigInput
|
export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSourceConfigInput
|
||||||
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
|
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
|
||||||
@@ -56,16 +46,10 @@ export type AdminCloudtentaclesSkuBuyRouteBody = AdminCloudtentaclesSkuBuyInput
|
|||||||
export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput
|
export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput
|
||||||
export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput
|
export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput
|
||||||
export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput
|
export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput
|
||||||
export type AdminInventoryCreateRouteBody = AdminInventoryCreateInput
|
|
||||||
export type AdminInventoryImportRouteBody = AdminInventoryImportInput
|
|
||||||
export type AdminInventoryInvalidateRouteBody = AdminInventoryInvalidateInput
|
|
||||||
export type AdminUserInventoryGroupsRouteBody = AdminUserInventoryGroupsInput
|
|
||||||
export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput
|
export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput
|
||||||
export type AdminTaskKuaishouCloudDispatchRouteBody = AdminTaskKuaishouCloudDispatchInput
|
export type AdminTaskKuaishouCloudDispatchRouteBody = AdminTaskKuaishouCloudDispatchInput
|
||||||
|
|
||||||
export type AdminInventoryRouteParams = { inventoryItemId?: string }
|
|
||||||
export type AdminOrderRouteParams = { orderId?: string }
|
export type AdminOrderRouteParams = { orderId?: string }
|
||||||
export type AdminTaskRouteParams = { taskId?: string, bindingId?: string }
|
export type AdminTaskRouteParams = { taskId?: string, bindingId?: string }
|
||||||
export type AdminWebhookEventRouteParams = { eventId?: string }
|
|
||||||
export type AdminEntityRouteParams = { id?: string }
|
export type AdminEntityRouteParams = { id?: string }
|
||||||
export type AdminCloudtentaclesSourceRouteParams = { sourceKey?: string }
|
export type AdminCloudtentaclesSourceRouteParams = { sourceKey?: string }
|
||||||
|
|||||||
@@ -1,32 +1,3 @@
|
|||||||
export type AdminInventoryCreateInput = {
|
|
||||||
skuCode?: string
|
|
||||||
displayValue?: string
|
|
||||||
batchNo?: string
|
|
||||||
credentialType?: string
|
|
||||||
inventoryGroupCode?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminInventoryImportRowInput = {
|
|
||||||
skuCode?: string
|
|
||||||
displayValue?: string
|
|
||||||
batchNo?: string
|
|
||||||
credentialType?: string
|
|
||||||
inventoryGroupCode?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminInventoryImportInput = {
|
|
||||||
rows?: AdminInventoryImportRowInput[]
|
|
||||||
codes?: unknown[]
|
|
||||||
skuCode?: string
|
|
||||||
batchNo?: string
|
|
||||||
credentialType?: string
|
|
||||||
inventoryGroupCode?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminUserInventoryGroupsInput = {
|
|
||||||
inventoryGroupCodes?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminKuaishouCloudFulfillmentItemInput = {
|
export type AdminKuaishouCloudFulfillmentItemInput = {
|
||||||
id?: string
|
id?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
@@ -249,10 +220,6 @@ export type AdminCloudtentaclesFullFlowInput = {
|
|||||||
vnKey?: string
|
vnKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminInventoryInvalidateInput = {
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminTaskManualDispatchInput = {
|
export type AdminTaskManualDispatchInput = {
|
||||||
outcome?: string
|
outcome?: string
|
||||||
resultMessage?: string
|
resultMessage?: string
|
||||||
|
|||||||
@@ -1,30 +1,3 @@
|
|||||||
import type {
|
|
||||||
AdminInventoryItemListItem,
|
|
||||||
AdminInventorySkuSuggestion,
|
|
||||||
} from './admin-read-models.js'
|
|
||||||
|
|
||||||
export type { AdminInventoryItemListItem, AdminInventorySkuSuggestion }
|
|
||||||
|
|
||||||
export type AdminInventoryMutationResponse = {
|
|
||||||
inventoryItem: AdminInventoryItemListItem | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminInventoryImportResponse = {
|
|
||||||
total: number
|
|
||||||
created: number
|
|
||||||
duplicated: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminInventoryGroupListResponse = {
|
|
||||||
items: AdminInventorySkuSuggestion[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminWebhookReplayResponse = {
|
|
||||||
eventId: number
|
|
||||||
replayed: boolean
|
|
||||||
result: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminTaskActionPayload = {
|
export type AdminTaskActionPayload = {
|
||||||
taskId: number
|
taskId: number
|
||||||
taskNo: string
|
taskNo: string
|
||||||
@@ -32,7 +5,6 @@ export type AdminTaskActionPayload = {
|
|||||||
deliveryStatus: string
|
deliveryStatus: string
|
||||||
resultCode: string
|
resultCode: string
|
||||||
resultMessage: string
|
resultMessage: string
|
||||||
inventoryItemId: number | null
|
|
||||||
primaryClaimTokenId: number | null
|
primaryClaimTokenId: number | null
|
||||||
lastError: string
|
lastError: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@@ -44,11 +16,6 @@ export type AdminTaskActionResponse = {
|
|||||||
token?: string
|
token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminTaskBindingReleaseResponse = AdminTaskActionResponse & {
|
|
||||||
bindingId: number
|
|
||||||
inventoryItemId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminManualDispatchOutcome = 'delivered' | 'failed'
|
export type AdminManualDispatchOutcome = 'delivered' | 'failed'
|
||||||
|
|
||||||
export type AdminTaskManualDispatchResponse = AdminTaskActionResponse & {
|
export type AdminTaskManualDispatchResponse = AdminTaskActionResponse & {
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ export type TaskCreateInput = {
|
|||||||
profileId: number | string
|
profileId: number | string
|
||||||
executorKey: string
|
executorKey: string
|
||||||
taskStatus?: string
|
taskStatus?: string
|
||||||
inventoryStatus?: string
|
|
||||||
deliveryStatus?: string
|
deliveryStatus?: string
|
||||||
resultCode?: string
|
resultCode?: string
|
||||||
resultMessage?: string
|
resultMessage?: string
|
||||||
@@ -102,7 +101,6 @@ export type TaskCreateInput = {
|
|||||||
|
|
||||||
export type TaskUpdatePatch = {
|
export type TaskUpdatePatch = {
|
||||||
task_status?: string
|
task_status?: string
|
||||||
inventory_status?: string
|
|
||||||
delivery_status?: string
|
delivery_status?: string
|
||||||
result_code?: string
|
result_code?: string
|
||||||
result_message?: string
|
result_message?: string
|
||||||
@@ -129,69 +127,3 @@ export type TaskUpdatePatch = {
|
|||||||
artifacts_json?: string | Record<string, unknown>
|
artifacts_json?: string | Record<string, unknown>
|
||||||
state_json?: string | Record<string, unknown>
|
state_json?: string | Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InventoryListQueryInput = {
|
|
||||||
page?: number
|
|
||||||
pageSize?: number
|
|
||||||
skuCode?: string
|
|
||||||
credentialType?: string
|
|
||||||
status?: string
|
|
||||||
batchNo?: string
|
|
||||||
inventoryGroupCode?: string
|
|
||||||
allowedInventoryGroupCodes?: string[] | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InventorySkuSuggestionQueryInput = {
|
|
||||||
credentialType?: string
|
|
||||||
keyword?: string
|
|
||||||
limit?: number
|
|
||||||
inventoryGroupCode?: string
|
|
||||||
allowedInventoryGroupCodes?: string[] | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InventoryCreateItemInput = {
|
|
||||||
batchNo?: string
|
|
||||||
skuCode: string
|
|
||||||
credentialType?: string
|
|
||||||
inventoryGroupCode?: string
|
|
||||||
displayValue: string
|
|
||||||
payload?: Record<string, unknown>
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebhookEventListQueryInput = {
|
|
||||||
page?: number
|
|
||||||
pageSize?: number
|
|
||||||
provider?: string
|
|
||||||
platform?: string
|
|
||||||
platformOrderId?: string
|
|
||||||
processed?: string
|
|
||||||
visibility?: string
|
|
||||||
relatedOrderId?: string
|
|
||||||
dateFrom?: string
|
|
||||||
dateTo?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebhookEventCreateInput = {
|
|
||||||
provider: string
|
|
||||||
platform: string
|
|
||||||
shopId?: string
|
|
||||||
shopName?: string
|
|
||||||
eventType: string
|
|
||||||
eventKey: string
|
|
||||||
signatureValid?: boolean
|
|
||||||
headersJson?: string | Record<string, unknown>
|
|
||||||
queryJson?: string | Record<string, unknown>
|
|
||||||
bodyJson?: string | Record<string, unknown>
|
|
||||||
processed?: boolean
|
|
||||||
processError?: string
|
|
||||||
relatedOrderId?: number | null
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebhookEventUpdatePatch = {
|
|
||||||
processed?: boolean
|
|
||||||
process_error?: string
|
|
||||||
related_order_id?: number | null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export type TaskRow = {
|
|||||||
delivery_status: string
|
delivery_status: string
|
||||||
result_code: string
|
result_code: string
|
||||||
result_message: string
|
result_message: string
|
||||||
inventory_status: string
|
|
||||||
automation_mode: string
|
automation_mode: string
|
||||||
requires_claim: boolean
|
requires_claim: boolean
|
||||||
user_action_status: string
|
user_action_status: string
|
||||||
@@ -64,12 +63,6 @@ export type TaskRow = {
|
|||||||
primary_claim_token_id: number | null
|
primary_claim_token_id: number | null
|
||||||
primary_claim_token_status: string
|
primary_claim_token_status: string
|
||||||
primary_claim_expires_at?: string | null
|
primary_claim_expires_at?: string | null
|
||||||
primary_inventory_item_id: number | null
|
|
||||||
primary_inventory_binding_status: string
|
|
||||||
inventory_display_value: string
|
|
||||||
primary_inventory_display_value: string
|
|
||||||
inventory_credential_type?: string
|
|
||||||
primary_inventory_credential_type?: string
|
|
||||||
artifacts_json: string | Record<string, unknown>
|
artifacts_json: string | Record<string, unknown>
|
||||||
context_json: string | Record<string, unknown>
|
context_json: string | Record<string, unknown>
|
||||||
screenshot_path: string
|
screenshot_path: string
|
||||||
@@ -84,80 +77,6 @@ export type TaskRow = {
|
|||||||
sku_name?: string
|
sku_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InventoryItemRow = {
|
|
||||||
id: number
|
|
||||||
sku_code: string
|
|
||||||
batch_no: string
|
|
||||||
credential_type: string
|
|
||||||
inventory_group_code: string
|
|
||||||
display_value: string
|
|
||||||
status: string
|
|
||||||
invalid_reason: string
|
|
||||||
delivered_at: string | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
reserved_by_task_id: number | null
|
|
||||||
reserved_by_task_no: string
|
|
||||||
platform_order_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InventorySkuSuggestionRow = {
|
|
||||||
sku_code: string
|
|
||||||
credential_type: string
|
|
||||||
inventory_group_code: string
|
|
||||||
total_count: number
|
|
||||||
available_count: number
|
|
||||||
latest_updated_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebhookEventRow = {
|
|
||||||
id: number
|
|
||||||
provider: string
|
|
||||||
platform: string
|
|
||||||
shop_id: string
|
|
||||||
shop_name: string
|
|
||||||
event_type: string
|
|
||||||
event_key: string
|
|
||||||
signature_valid: boolean
|
|
||||||
headers_json: string | Record<string, unknown>
|
|
||||||
query_json: string | Record<string, unknown>
|
|
||||||
body_json: string | Record<string, unknown>
|
|
||||||
processed: boolean
|
|
||||||
process_error: string
|
|
||||||
related_order_id: number | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TaskInventoryBindingRow = {
|
|
||||||
id: number
|
|
||||||
task_id: number
|
|
||||||
inventory_item_id: number
|
|
||||||
role_key: string
|
|
||||||
quantity: number
|
|
||||||
binding_status: string
|
|
||||||
metadata_json: string | Record<string, unknown>
|
|
||||||
invalid_reason: string
|
|
||||||
consumed_at: string | null
|
|
||||||
released_at: string | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
sku_code: string
|
|
||||||
batch_no: string
|
|
||||||
credential_type: string
|
|
||||||
inventory_group_code: string
|
|
||||||
display_value: string
|
|
||||||
inventory_status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TaskInventoryBindingSummaryRow = {
|
|
||||||
task_id: number
|
|
||||||
total_binding_count: number
|
|
||||||
reserved_binding_count: number
|
|
||||||
consumed_binding_count: number
|
|
||||||
released_binding_count: number
|
|
||||||
role_keys: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ClaimTokenRow = {
|
export type ClaimTokenRow = {
|
||||||
id: number
|
id: number
|
||||||
token: string
|
token: string
|
||||||
@@ -183,13 +102,3 @@ export type TaskListQueryResult = {
|
|||||||
items: TaskRow[]
|
items: TaskRow[]
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InventoryListQueryResult = {
|
|
||||||
items: InventoryItemRow[]
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebhookEventListQueryResult = {
|
|
||||||
items: WebhookEventRow[]
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test('shouldWriteLog respects configured minimum log level', () => {
|
|||||||
|
|
||||||
test('resolveLogFilePath uses daily log file names by channel', () => {
|
test('resolveLogFilePath uses daily log file names by channel', () => {
|
||||||
assert.match(resolveLogFilePath('app', '2026-04-14T10:00:00.000Z'), /app-2026-04-14\.log$/)
|
assert.match(resolveLogFilePath('app', '2026-04-14T10:00:00.000Z'), /app-2026-04-14\.log$/)
|
||||||
assert.match(resolveLogFilePath('webhook', '2026-04-14T10:00:00.000Z'), /webhook-2026-04-14\.log$/)
|
assert.match(resolveLogFilePath('integration', '2026-04-14T10:00:00.000Z'), /integration-2026-04-14\.log$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('resolveExpiredLogFilenames keeps latest seven days and ignores unknown files', () => {
|
test('resolveExpiredLogFilenames keeps latest seven days and ignores unknown files', () => {
|
||||||
@@ -37,17 +37,17 @@ test('resolveExpiredLogFilenames keeps latest seven days and ignores unknown fil
|
|||||||
'app-2026-04-13.log',
|
'app-2026-04-13.log',
|
||||||
'app-2026-04-08.log',
|
'app-2026-04-08.log',
|
||||||
'app-2026-04-07.log',
|
'app-2026-04-07.log',
|
||||||
'webhook-2026-04-06.log',
|
'integration-2026-04-06.log',
|
||||||
'app.log',
|
'app.log',
|
||||||
'webhook.log',
|
'integration.log',
|
||||||
'random.txt',
|
'random.txt',
|
||||||
], '2026-04-14T12:00:00.000Z', 7)
|
], '2026-04-14T12:00:00.000Z', 7)
|
||||||
|
|
||||||
assert.deepEqual(expired, [
|
assert.deepEqual(expired, [
|
||||||
'app-2026-04-07.log',
|
'app-2026-04-07.log',
|
||||||
'webhook-2026-04-06.log',
|
'integration-2026-04-06.log',
|
||||||
'app.log',
|
'app.log',
|
||||||
'webhook.log',
|
'integration.log',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const DATA_ROOT = resolveDataRoot()
|
|||||||
const LOG_DIR = path.join(DATA_ROOT, 'logs')
|
const LOG_DIR = path.join(DATA_ROOT, 'logs')
|
||||||
const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level)
|
const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level)
|
||||||
const LOG_RETENTION_DAYS = 7
|
const LOG_RETENTION_DAYS = 7
|
||||||
const LEGACY_LOG_FILES = new Set(['app.log', 'webhook.log'])
|
const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log'])
|
||||||
|
|
||||||
let writeQueue = Promise.resolve()
|
let writeQueue = Promise.resolve()
|
||||||
let lastCleanupDateKey = ''
|
let lastCleanupDateKey = ''
|
||||||
@@ -60,8 +60,8 @@ export function logError(scope, message, detail = undefined) {
|
|||||||
return writeLog('error', scope, message, detail)
|
return writeLog('error', scope, message, detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logWebhook(scope, message, detail = undefined, { level = 'info' } = {}) {
|
export function logIntegration(scope, message, detail = undefined, { level = 'info' } = {}) {
|
||||||
return writeLog(level, scope, message, detail, { channel: 'webhook' })
|
return writeLog(level, scope, message, detail, { channel: 'integration' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLog(level, scope, message, detail, { channel = 'app' } = {}) {
|
function writeLog(level, scope, message, detail, { channel = 'app' } = {}) {
|
||||||
@@ -167,7 +167,7 @@ export function formatLogEntry(entry, { color = false } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()) {
|
export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()) {
|
||||||
const normalizedChannel = String(channel || '').trim() === 'webhook' ? 'webhook' : 'app'
|
const normalizedChannel = String(channel || '').trim() === 'integration' ? 'integration' : 'app'
|
||||||
return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`)
|
return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +392,7 @@ function shouldDeleteLogFileByDate(fileName, cutoffDateKey) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = /^(app|webhook)-(\d{4}-\d{2}-\d{2})\.log$/.exec(String(fileName || '').trim())
|
const matched = /^(app|integration)-(\d{4}-\d{2}-\d{2})\.log$/.exec(String(fileName || '').trim())
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ function resolveType(status: string): ElTagType {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
'waiting_inventory',
|
|
||||||
'retry_pending',
|
'retry_pending',
|
||||||
'manual_review',
|
'manual_review',
|
||||||
'invalid',
|
'invalid',
|
||||||
@@ -98,7 +97,6 @@ function resolveLabel(status: string) {
|
|||||||
refunding: '退款中',
|
refunding: '退款中',
|
||||||
closed: '已关闭',
|
closed: '已关闭',
|
||||||
pending_payment: '待支付',
|
pending_payment: '待支付',
|
||||||
waiting_inventory: '待库存',
|
|
||||||
cdk_reserved: '已预占',
|
cdk_reserved: '已预占',
|
||||||
link_generated: '已生成链接',
|
link_generated: '已生成链接',
|
||||||
claimed: '已创建会话',
|
claimed: '已创建会话',
|
||||||
|
|||||||
@@ -53,20 +53,6 @@ const router = createRouter({
|
|||||||
path: 'tasks/:taskId',
|
path: 'tasks/:taskId',
|
||||||
component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'),
|
component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'cdks',
|
|
||||||
redirect: '/admin/inventory',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'inventory',
|
|
||||||
meta: { allowedRoles: ['admin', 'operator'] },
|
|
||||||
component: () => import('@/views/admin/inventory/AdminInventoryView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'webhook-events',
|
|
||||||
meta: { allowedRoles: ['admin', 'operator'] },
|
|
||||||
component: () => import('@/views/admin/webhook-events/AdminWebhookEventsView.vue'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'platform-shops',
|
path: 'platform-shops',
|
||||||
meta: { allowedRoles: ['admin'] },
|
meta: { allowedRoles: ['admin'] },
|
||||||
@@ -82,11 +68,6 @@ const router = createRouter({
|
|||||||
meta: { allowedRoles: ['admin'] },
|
meta: { allowedRoles: ['admin'] },
|
||||||
redirect: '/admin/platform-fulfillment?tab=kuaishou-cloud',
|
redirect: '/admin/platform-fulfillment?tab=kuaishou-cloud',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'webhook-events/:eventId',
|
|
||||||
meta: { allowedRoles: ['admin', 'operator'] },
|
|
||||||
component: () => import('@/views/admin/webhook-events/AdminWebhookEventDetailView.vue'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'audit-logs',
|
path: 'audit-logs',
|
||||||
meta: { allowedRoles: ['admin'] },
|
meta: { allowedRoles: ['admin'] },
|
||||||
|
|||||||
@@ -5,5 +5,3 @@ export * from './audit-logs'
|
|||||||
export * from './platform-config'
|
export * from './platform-config'
|
||||||
export * from './orders'
|
export * from './orders'
|
||||||
export * from './tasks'
|
export * from './tasks'
|
||||||
export * from './inventory'
|
|
||||||
export * from './webhook-events'
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { apiGet, apiPost } from '@/lib/http'
|
|
||||||
import type {
|
|
||||||
AdminInventoryItemListItem,
|
|
||||||
AdminInventorySkuSuggestion,
|
|
||||||
AdminPagination,
|
|
||||||
} from '@/types/admin'
|
|
||||||
|
|
||||||
export function fetchAdminInventoryItems(params?: Record<string, unknown>) {
|
|
||||||
return apiGet<{ items: AdminInventoryItemListItem[]; pagination: AdminPagination }>(
|
|
||||||
'/api/v1/admin/inventory',
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchAdminInventorySkuSuggestions(params?: Record<string, unknown>) {
|
|
||||||
return apiGet<{ items: AdminInventorySkuSuggestion[] }>(
|
|
||||||
'/api/v1/admin/inventory/sku-suggestions',
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importAdminInventoryItems(payload: Record<string, unknown>) {
|
|
||||||
return apiPost<{ total: number; created: number; duplicated: number }>(
|
|
||||||
'/api/v1/admin/inventory/import',
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminInventoryItem(payload: Record<string, unknown>) {
|
|
||||||
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(
|
|
||||||
'/api/v1/admin/inventory',
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function releaseAdminInventoryItem(inventoryItemId: number | string) {
|
|
||||||
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(
|
|
||||||
`/api/v1/admin/inventory/${inventoryItemId}/release`,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateAdminInventoryItem(
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
payload?: Record<string, unknown>,
|
|
||||||
) {
|
|
||||||
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(
|
|
||||||
`/api/v1/admin/inventory/${inventoryItemId}/invalidate`,
|
|
||||||
payload || {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -25,20 +25,6 @@ export function retryAdminTask(taskId: number | string) {
|
|||||||
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/retry`, {})
|
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/retry`, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releaseAdminTaskInventory(taskId: number | string) {
|
|
||||||
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/release-inventory`, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function releaseAdminTaskInventoryBinding(
|
|
||||||
taskId: number | string,
|
|
||||||
bindingId: number | string,
|
|
||||||
) {
|
|
||||||
return apiPost<AdminTaskActionResponse>(
|
|
||||||
`/api/v1/admin/tasks/${taskId}/inventory-bindings/${bindingId}/release`,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function regenerateAdminTaskClaimLink(taskId: number | string) {
|
export function regenerateAdminTaskClaimLink(taskId: number | string) {
|
||||||
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/regenerate-claim-link`, {})
|
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/regenerate-claim-link`, {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,16 +25,6 @@ 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 }>(
|
return apiPost<{ user: AdminUserListItem }>(
|
||||||
`/api/v1/admin/users/${userId}/reset-password`,
|
`/api/v1/admin/users/${userId}/reset-password`,
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { apiGet, apiPost } from '@/lib/http'
|
|
||||||
import type {
|
|
||||||
AdminPagination,
|
|
||||||
AdminWebhookEventDetail,
|
|
||||||
AdminWebhookEventListItem,
|
|
||||||
AdminWebhookReplayResponse,
|
|
||||||
} from '@/types/admin'
|
|
||||||
|
|
||||||
export function fetchAdminWebhookEvents(params?: Record<string, unknown>) {
|
|
||||||
return apiGet<{ items: AdminWebhookEventListItem[]; pagination: AdminPagination }>(
|
|
||||||
'/api/v1/admin/webhook-events',
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchAdminWebhookEventDetail(eventId: number | string) {
|
|
||||||
return apiGet<AdminWebhookEventDetail>(`/api/v1/admin/webhook-events/${eventId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replayAdminWebhookEvent(eventId: number | string) {
|
|
||||||
return apiPost<AdminWebhookReplayResponse>(`/api/v1/admin/webhook-events/${eventId}/replay`, {})
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
管理员列表页共享样式
|
管理员列表页共享样式
|
||||||
引入: AdminOrdersView, AdminTasksView, AdminWebhookEventsView,
|
引入: AdminOrdersView, AdminTasksView, AdminUsersView
|
||||||
AdminUsersView, AdminInventoryView
|
|
||||||
各页面自行覆盖 grid-template-columns 等布局差异
|
各页面自行覆盖 grid-template-columns 等布局差异
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export interface AdminLoginResponse {
|
|||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
role: AdminRole
|
role: AdminRole
|
||||||
inventoryGroupCodes?: string[]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +18,5 @@ export interface AdminSessionSummary {
|
|||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
role: AdminRole
|
role: AdminRole
|
||||||
inventoryGroupCodes?: string[]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,4 @@ export interface AdminDashboardSummary {
|
|||||||
claimingTasks: number
|
claimingTasks: number
|
||||||
redeemedToday: number
|
redeemedToday: number
|
||||||
abnormalTasks: number
|
abnormalTasks: number
|
||||||
skuWithInventory: number
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,16 +29,6 @@ export type {
|
|||||||
AdminTaskDetail,
|
AdminTaskDetail,
|
||||||
} from './tasks'
|
} from './tasks'
|
||||||
|
|
||||||
// Inventory types
|
|
||||||
export type { AdminInventorySkuSuggestion, AdminInventoryItemListItem } from './inventory'
|
|
||||||
|
|
||||||
// Webhook events types
|
|
||||||
export type {
|
|
||||||
AdminWebhookEventListItem,
|
|
||||||
AdminWebhookEventDetail,
|
|
||||||
AdminWebhookReplayResponse,
|
|
||||||
} from './webhook-events'
|
|
||||||
|
|
||||||
// Platform config types
|
// Platform config types
|
||||||
export type {
|
export type {
|
||||||
AdminNotificationBarkRecipient,
|
AdminNotificationBarkRecipient,
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
export interface AdminInventorySkuSuggestion {
|
|
||||||
skuCode: string
|
|
||||||
credentialType: string
|
|
||||||
inventoryGroupCode: string
|
|
||||||
totalCount: number
|
|
||||||
availableCount: number
|
|
||||||
latestUpdatedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminInventoryItemListItem {
|
|
||||||
inventoryItemId: number
|
|
||||||
skuCode: string
|
|
||||||
batchNo: string
|
|
||||||
credentialType: string
|
|
||||||
inventoryGroupCode: string
|
|
||||||
displayValue: string
|
|
||||||
status: string
|
|
||||||
reservedByTaskId: number | null
|
|
||||||
reservedByTaskNo: string
|
|
||||||
platformOrderId: string
|
|
||||||
systemBindingStatus: string
|
|
||||||
userBindingStatus: string
|
|
||||||
invalidReason: string
|
|
||||||
deliveredAt: string | null
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
@@ -77,17 +77,4 @@ export interface AdminOrderDetail {
|
|||||||
spec: Record<string, unknown>
|
spec: Record<string, unknown>
|
||||||
}>
|
}>
|
||||||
tasks: AdminTaskListItem[]
|
tasks: AdminTaskListItem[]
|
||||||
webhookEvents: Array<{
|
|
||||||
eventId: number
|
|
||||||
provider: string
|
|
||||||
platform: string
|
|
||||||
shopId: string
|
|
||||||
shopName: string
|
|
||||||
eventType: string
|
|
||||||
eventKey: string
|
|
||||||
signatureValid: boolean
|
|
||||||
processed: boolean
|
|
||||||
processError: string
|
|
||||||
createdAt: string
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,15 +31,12 @@ export interface AdminTaskListItem {
|
|||||||
lastError: string
|
lastError: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
inventoryDisplayMasked: string
|
|
||||||
inventoryCredentialType: string
|
|
||||||
claimToken: string
|
claimToken: string
|
||||||
screenshotPath: string
|
screenshotPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminTaskOperations {
|
export interface AdminTaskOperations {
|
||||||
canRetry: boolean
|
canRetry: boolean
|
||||||
canReleaseInventory: boolean
|
|
||||||
canRegenerateClaimLink: boolean
|
canRegenerateClaimLink: boolean
|
||||||
canClose: boolean
|
canClose: boolean
|
||||||
canMarkManualReview: boolean
|
canMarkManualReview: boolean
|
||||||
@@ -59,7 +56,6 @@ export interface AdminTaskActionResponse {
|
|||||||
deliveryStatus: string
|
deliveryStatus: string
|
||||||
resultCode: string
|
resultCode: string
|
||||||
resultMessage: string
|
resultMessage: string
|
||||||
inventoryItemId: number | null
|
|
||||||
primaryClaimTokenId: number | null
|
primaryClaimTokenId: number | null
|
||||||
lastError: string
|
lastError: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@@ -93,34 +89,6 @@ export interface AdminTaskDetail {
|
|||||||
expiredAt: string
|
expiredAt: string
|
||||||
claimUrl: string
|
claimUrl: string
|
||||||
}
|
}
|
||||||
inventory: null | {
|
|
||||||
inventoryItemId: number
|
|
||||||
skuCode: string
|
|
||||||
batchNo: string
|
|
||||||
credentialType: string
|
|
||||||
displayValue: string
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
inventoryBindings: Array<{
|
|
||||||
bindingId: number
|
|
||||||
inventoryItemId: number
|
|
||||||
roleKey: string
|
|
||||||
quantity: number
|
|
||||||
bindingStatus: string
|
|
||||||
inventoryStatus: string
|
|
||||||
skuCode: string
|
|
||||||
batchNo: string
|
|
||||||
credentialType: string
|
|
||||||
displayValue: string
|
|
||||||
invalidReason: string
|
|
||||||
consumedAt: string | null
|
|
||||||
releasedAt: string | null
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
metadata: Record<string, unknown>
|
|
||||||
isPrimary: boolean
|
|
||||||
canRelease: boolean
|
|
||||||
}>
|
|
||||||
artifacts: Record<string, unknown>
|
artifacts: Record<string, unknown>
|
||||||
screenshotUrl: string
|
screenshotUrl: string
|
||||||
review: {
|
review: {
|
||||||
@@ -136,7 +104,6 @@ export interface AdminTaskDetail {
|
|||||||
finishedAt: string | null
|
finishedAt: string | null
|
||||||
attempts: Array<{
|
attempts: Array<{
|
||||||
attempt: number
|
attempt: number
|
||||||
inventoryItemId: number | null
|
|
||||||
codeMasked: string
|
codeMasked: string
|
||||||
credentialType: string
|
credentialType: string
|
||||||
outcome: string
|
outcome: string
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
export interface AdminWebhookEventListItem {
|
|
||||||
eventId: number
|
|
||||||
provider: string
|
|
||||||
platform: string
|
|
||||||
platformRaw: string
|
|
||||||
shopId: string
|
|
||||||
shopName: string
|
|
||||||
eventType: string
|
|
||||||
eventKey: string
|
|
||||||
signatureValid: boolean
|
|
||||||
processed: boolean
|
|
||||||
processError: string
|
|
||||||
visibilityLevel: string
|
|
||||||
relatedOrderId: number | null
|
|
||||||
platformOrderId: string
|
|
||||||
buyerId: string
|
|
||||||
buyerName: string
|
|
||||||
totalAmount: string
|
|
||||||
totalAmountFen: number
|
|
||||||
currency: string
|
|
||||||
itemCount: number
|
|
||||||
taskCount: number
|
|
||||||
messageText: string
|
|
||||||
requestTimestamp: string
|
|
||||||
requestSign: string
|
|
||||||
aopic: string
|
|
||||||
sourceHost: string
|
|
||||||
sourceIp: string
|
|
||||||
userAgent: string
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminWebhookEventDetail extends AdminWebhookEventListItem {
|
|
||||||
headers: Record<string, unknown>
|
|
||||||
query: Record<string, unknown>
|
|
||||||
body: Record<string, unknown>
|
|
||||||
payload: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminWebhookReplayResponse {
|
|
||||||
eventId: number
|
|
||||||
replayed: boolean
|
|
||||||
result: {
|
|
||||||
accepted: boolean
|
|
||||||
provider: string
|
|
||||||
platform: string
|
|
||||||
platformRaw: string
|
|
||||||
shopId: string
|
|
||||||
shopName: string
|
|
||||||
eventType: string
|
|
||||||
platformOrderId: string
|
|
||||||
orderId: number
|
|
||||||
taskCount: number
|
|
||||||
tasks: Array<{
|
|
||||||
taskId: number
|
|
||||||
taskNo: string
|
|
||||||
status: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,13 +3,11 @@ export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (stri
|
|||||||
export type ClaimTaskStatus =
|
export type ClaimTaskStatus =
|
||||||
| 'pending_payment'
|
| 'pending_payment'
|
||||||
| 'paid'
|
| 'paid'
|
||||||
| 'waiting_inventory'
|
|
||||||
| 'pending_binding_prepare'
|
| 'pending_binding_prepare'
|
||||||
| 'waiting_binding'
|
| 'waiting_binding'
|
||||||
| 'dispatched_pending_return'
|
| 'dispatched_pending_return'
|
||||||
| 'completed'
|
| 'completed'
|
||||||
| 'failed'
|
| 'failed'
|
||||||
| 'cdk_reserved'
|
|
||||||
| 'link_generated'
|
| 'link_generated'
|
||||||
| 'claimed'
|
| 'claimed'
|
||||||
| 'role_confirmed'
|
| 'role_confirmed'
|
||||||
|
|||||||
@@ -24,15 +24,10 @@ export function formatAuditAction(action: string) {
|
|||||||
admin_user_role_updated: "修改用户角色",
|
admin_user_role_updated: "修改用户角色",
|
||||||
admin_user_status_updated: "修改用户状态",
|
admin_user_status_updated: "修改用户状态",
|
||||||
admin_user_password_reset: "重置用户密码",
|
admin_user_password_reset: "重置用户密码",
|
||||||
task_release_inventory: "释放任务库存",
|
|
||||||
task_release_inventory_binding: "释放任务库存绑定",
|
|
||||||
task_regenerate_claim_link: "重发领取链接",
|
task_regenerate_claim_link: "重发领取链接",
|
||||||
task_closed: "关闭任务",
|
task_closed: "关闭任务",
|
||||||
task_mark_manual_review: "转人工处理",
|
task_mark_manual_review: "转人工处理",
|
||||||
task_complete_manual_dispatch: "完成人工履约",
|
task_complete_manual_dispatch: "完成人工履约",
|
||||||
inventory_item_released: "释放库存项",
|
|
||||||
inventory_item_invalidated: "作废库存项",
|
|
||||||
webhook_replayed: "重放 Webhook",
|
|
||||||
platform_shop_config_updated: "更新店铺配置",
|
platform_shop_config_updated: "更新店铺配置",
|
||||||
platform_fulfillment_config_updated: "更新履约配置",
|
platform_fulfillment_config_updated: "更新履约配置",
|
||||||
};
|
};
|
||||||
@@ -44,9 +39,6 @@ export function formatAuditTargetType(targetType: string) {
|
|||||||
const labelMap: Record<string, string> = {
|
const labelMap: Record<string, string> = {
|
||||||
admin_user: "后台用户",
|
admin_user: "后台用户",
|
||||||
task: "交付任务",
|
task: "交付任务",
|
||||||
task_inventory_binding: "任务库存绑定",
|
|
||||||
inventory_item: "库存项",
|
|
||||||
webhook_event: "Webhook 事件",
|
|
||||||
platform_config: "平台配置",
|
platform_config: "平台配置",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,10 +48,8 @@ export function formatAuditTargetType(targetType: string) {
|
|||||||
export function formatTaskEventType(eventType: string) {
|
export function formatTaskEventType(eventType: string) {
|
||||||
const labelMap: Record<string, string> = {
|
const labelMap: Record<string, string> = {
|
||||||
manual_dispatch_completed: "人工履约已回写",
|
manual_dispatch_completed: "人工履约已回写",
|
||||||
inventory_binding_released: "库存绑定已释放",
|
|
||||||
claim_redeem_code_used: "兑换码已使用",
|
claim_redeem_code_used: "兑换码已使用",
|
||||||
claim_redeem_code_invalid: "兑换码错误",
|
claim_redeem_code_invalid: "兑换码错误",
|
||||||
claim_redeem_inventory_replaced: "已切换新兑换码",
|
|
||||||
claim_redeem_completed: "兑换链路完成",
|
claim_redeem_completed: "兑换链路完成",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,11 +104,6 @@ export function formatTaskEventPayload(payload: Record<string, unknown>) {
|
|||||||
payload.codeMasked ? `凭据 ${payload.codeMasked}` : "",
|
payload.codeMasked ? `凭据 ${payload.codeMasked}` : "",
|
||||||
payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : "",
|
payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : "",
|
||||||
payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : "",
|
payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : "",
|
||||||
payload.inventoryItemId ? `库存项 #${payload.inventoryItemId}` : "",
|
|
||||||
payload.previousInventoryItemId
|
|
||||||
? `旧库存 #${payload.previousInventoryItemId}`
|
|
||||||
: "",
|
|
||||||
payload.nextInventoryItemId ? `新库存 #${payload.nextInventoryItemId}` : "",
|
|
||||||
payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : "",
|
payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : "",
|
||||||
payload.vnId ? `虚拟号 #${payload.vnId}` : "",
|
payload.vnId ? `虚拟号 #${payload.vnId}` : "",
|
||||||
payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : "",
|
payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : "",
|
||||||
@@ -147,20 +132,3 @@ export function formatTaskEventPayload(payload: Record<string, unknown>) {
|
|||||||
|
|
||||||
return JSON.stringify(payload || {});
|
return JSON.stringify(payload || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatBindingRoleSummary(
|
|
||||||
roleKeys: string[] | undefined | null,
|
|
||||||
emptyLabel = "-"
|
|
||||||
) {
|
|
||||||
if (!Array.isArray(roleKeys) || roleKeys.length === 0) return emptyLabel;
|
|
||||||
return roleKeys.join(" / ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatBindingCountSummary(summary: {
|
|
||||||
totalBindingCount: number;
|
|
||||||
reservedBindingCount: number;
|
|
||||||
consumedBindingCount: number;
|
|
||||||
releasedBindingCount: number;
|
|
||||||
}) {
|
|
||||||
return `共 ${summary.totalBindingCount} 条 · 预占 ${summary.reservedBindingCount} · 已消费 ${summary.consumedBindingCount} · 已释放 ${summary.releasedBindingCount}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ export const adminTaskStatusOptions = [
|
|||||||
{ label: '全部', value: '' },
|
{ label: '全部', value: '' },
|
||||||
{ label: '待支付', value: 'pending_payment' },
|
{ label: '待支付', value: 'pending_payment' },
|
||||||
{ label: '已支付', value: 'paid' },
|
{ label: '已支付', value: 'paid' },
|
||||||
{ label: '待库存', value: 'waiting_inventory' },
|
|
||||||
{ label: '已预占', value: 'cdk_reserved' },
|
|
||||||
{ label: '已生成链接', value: 'link_generated' },
|
{ label: '已生成链接', value: 'link_generated' },
|
||||||
{ label: '已创建会话', value: 'claimed' },
|
{ label: '已创建会话', value: 'claimed' },
|
||||||
{ label: '已确认角色', value: 'role_confirmed' },
|
{ label: '已确认角色', value: 'role_confirmed' },
|
||||||
@@ -23,36 +21,6 @@ export const adminTaskStatusOptions = [
|
|||||||
{ label: '已关闭', value: 'closed' },
|
{ label: '已关闭', value: 'closed' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const adminInventoryStatusOptions = [
|
|
||||||
{ label: '全部', value: '' },
|
|
||||||
{ label: '可用', value: 'available' },
|
|
||||||
{ label: '已预占', value: 'reserved' },
|
|
||||||
{ label: '已发放', value: 'delivered' },
|
|
||||||
{ label: '已消耗', value: 'consumed' },
|
|
||||||
{ label: '已作废', value: 'invalid' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const adminInventoryCredentialTypeOptions = [
|
|
||||||
{ label: '全部凭据类型', value: '' },
|
|
||||||
{ label: '兑换码(领取链路)', value: 'claim_code' },
|
|
||||||
{ label: '卡号卡密', value: 'card_password' },
|
|
||||||
{ label: '账号密码', value: 'account_password' },
|
|
||||||
{ label: '激活链接', value: 'activation_link' },
|
|
||||||
{ label: '纯文本凭据', value: 'text_credential' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const adminWebhookProcessedOptions = [
|
|
||||||
{ label: '全部', value: '' },
|
|
||||||
{ label: '处理成功', value: '1' },
|
|
||||||
{ label: '处理失败', value: '0' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const adminWebhookVisibilityOptions = [
|
|
||||||
{ label: '仅重要', value: 'important' },
|
|
||||||
{ label: '仅忽略', value: 'ignored' },
|
|
||||||
{ label: '全部', value: 'all' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const adminUserRoleOptions = [
|
export const adminUserRoleOptions = [
|
||||||
{ label: '全部', value: '' },
|
{ label: '全部', value: '' },
|
||||||
{ label: '管理员', value: 'admin' },
|
{ label: '管理员', value: 'admin' },
|
||||||
@@ -71,21 +39,14 @@ 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_regenerate_claim_link' },
|
{ label: '重发领取链接', value: 'task_regenerate_claim_link' },
|
||||||
{ label: '关闭任务', value: 'task_closed' },
|
{ label: '关闭任务', value: 'task_closed' },
|
||||||
{ label: '转人工处理', value: 'task_mark_manual_review' },
|
{ label: '转人工处理', value: 'task_mark_manual_review' },
|
||||||
{ label: '释放库存项', value: 'inventory_item_released' },
|
|
||||||
{ label: '作废库存项', value: 'inventory_item_invalidated' },
|
|
||||||
{ label: '重放 Webhook', value: 'webhook_replayed' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const adminAuditTargetTypeOptions = [
|
export const adminAuditTargetTypeOptions = [
|
||||||
{ label: '全部', value: '' },
|
{ label: '全部', value: '' },
|
||||||
{ label: '后台用户', value: 'admin_user' },
|
{ label: '后台用户', value: 'admin_user' },
|
||||||
{ label: '交付任务', value: 'task' },
|
{ label: '交付任务', value: 'task' },
|
||||||
{ label: '库存项', value: 'inventory_item' },
|
|
||||||
{ label: 'Webhook 事件', value: 'webhook_event' },
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { formatStatusWithRaw } from '@/utils/admin-display'
|
|
||||||
|
|
||||||
export function formatAdminWebhookEventType(eventType: string) {
|
|
||||||
const normalized = String(eventType || '').trim()
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelMap: Record<string, string> = {
|
|
||||||
payment_success: '支付成功',
|
|
||||||
trade_create: '订单创建',
|
|
||||||
trade_closed: '订单关闭',
|
|
||||||
buyer_confirm_goods: '确认收货',
|
|
||||||
auto_delivery_done: '自动发货完成',
|
|
||||||
auto_delivery_confirmed: '自动发货确认完成',
|
|
||||||
refund_apply: '买家申请退款',
|
|
||||||
trade_event: '交易事件',
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatStatusWithRaw(normalized, labelMap)
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ const cards = [
|
|||||||
{ key: 'claimingTasks', label: '领取中' },
|
{ key: 'claimingTasks', label: '领取中' },
|
||||||
{ key: 'redeemedToday', label: '今日成功' },
|
{ key: 'redeemedToday', label: '今日成功' },
|
||||||
{ key: 'abnormalTasks', label: '异常任务' },
|
{ key: 'abnormalTasks', label: '异常任务' },
|
||||||
{ key: 'skuWithInventory', label: '有库存 SKU' },
|
|
||||||
] as const
|
] as const
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ defineProps<{
|
|||||||
|
|
||||||
<div class="overview-notes">
|
<div class="overview-notes">
|
||||||
<el-tag effect="plain">91卡券订单来自已接收的待补全队列</el-tag>
|
<el-tag effect="plain">91卡券订单来自已接收的待补全队列</el-tag>
|
||||||
<el-tag effect="plain">内部 SKU 决定最终任务与库存绑定</el-tag>
|
<el-tag effect="plain">内部 SKU 决定任务与云资源匹配</el-tag>
|
||||||
<el-tag effect="plain">cloud SKU 决定自动购买、发货与退号资源</el-tag>
|
<el-tag effect="plain">cloud SKU 决定自动购买、发货与退号资源</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
|
||||||
|
|
||||||
import AdminInventoryEntryCard from './components/AdminInventoryEntryCard.vue'
|
|
||||||
import AdminInventoryFilterCard from './components/AdminInventoryFilterCard.vue'
|
|
||||||
import AdminInventoryPoolGrid from './components/AdminInventoryPoolGrid.vue'
|
|
||||||
import AdminInventoryTable from './components/AdminInventoryTable.vue'
|
|
||||||
import { useAdminInventory } from './composables/useAdminInventory'
|
|
||||||
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
importing,
|
|
||||||
creating,
|
|
||||||
actionLoadingId,
|
|
||||||
errorMessage,
|
|
||||||
items,
|
|
||||||
pagination,
|
|
||||||
pageSummary,
|
|
||||||
pageOverview,
|
|
||||||
inventoryGroups,
|
|
||||||
// filter
|
|
||||||
filterSkuCode,
|
|
||||||
filterCredentialType,
|
|
||||||
filterStatus,
|
|
||||||
filterBatchNo,
|
|
||||||
filterInventoryGroupCode,
|
|
||||||
// entry
|
|
||||||
entrySkuCode,
|
|
||||||
entryCredentialType,
|
|
||||||
entryBatchNo,
|
|
||||||
entryInventoryGroupCode,
|
|
||||||
singleDisplayValue,
|
|
||||||
bulkText,
|
|
||||||
skuSuggestionsLoading,
|
|
||||||
entrySkuSuggestions,
|
|
||||||
// actions
|
|
||||||
loadInventoryItems,
|
|
||||||
resetFilters,
|
|
||||||
submitCreate,
|
|
||||||
submitImport,
|
|
||||||
runRowAction,
|
|
||||||
useGroupAsFilter,
|
|
||||||
useGroupAsEntry,
|
|
||||||
applySkuSuggestion,
|
|
||||||
// formatters
|
|
||||||
getCredentialTypeLabel,
|
|
||||||
getCredentialTypeHint,
|
|
||||||
getSkuNamingHint,
|
|
||||||
formatInventoryStatus,
|
|
||||||
getGroupSuggestion,
|
|
||||||
// utils
|
|
||||||
formatAdminDateTime,
|
|
||||||
} = useAdminInventory()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="inventory-page list-page">
|
|
||||||
<AdminPageHeader title="库存管理" description="按内部履约 SKU 和凭据类型管理库存、预占和发放。">
|
|
||||||
<template #extra>
|
|
||||||
<span class="total-badge">共 {{ pagination.total }} 条库存</span>
|
|
||||||
</template>
|
|
||||||
</AdminPageHeader>
|
|
||||||
|
|
||||||
<AdminInventoryFilterCard
|
|
||||||
v-model:filter-sku-code="filterSkuCode"
|
|
||||||
v-model:filter-credential-type="filterCredentialType"
|
|
||||||
v-model:filter-status="filterStatus"
|
|
||||||
v-model:filter-batch-no="filterBatchNo"
|
|
||||||
v-model:filter-inventory-group-code="filterInventoryGroupCode"
|
|
||||||
:load-inventory-items="loadInventoryItems"
|
|
||||||
:reset-filters="resetFilters"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminInventoryEntryCard
|
|
||||||
v-model:entry-sku-code="entrySkuCode"
|
|
||||||
v-model:entry-credential-type="entryCredentialType"
|
|
||||||
v-model:entry-batch-no="entryBatchNo"
|
|
||||||
v-model:entry-inventory-group-code="entryInventoryGroupCode"
|
|
||||||
v-model:single-display-value="singleDisplayValue"
|
|
||||||
v-model:bulk-text="bulkText"
|
|
||||||
:creating="creating"
|
|
||||||
:importing="importing"
|
|
||||||
:sku-suggestions-loading="skuSuggestionsLoading"
|
|
||||||
:entry-sku-suggestions="entrySkuSuggestions"
|
|
||||||
:submit-create="submitCreate"
|
|
||||||
:submit-import="submitImport"
|
|
||||||
:apply-sku-suggestion="applySkuSuggestion"
|
|
||||||
:get-credential-type-label="getCredentialTypeLabel"
|
|
||||||
:get-credential-type-hint="getCredentialTypeHint"
|
|
||||||
:get-sku-naming-hint="getSkuNamingHint"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="errorMessage"
|
|
||||||
:title="errorMessage"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mt-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-card
|
|
||||||
shadow="never"
|
|
||||||
class="section-card"
|
|
||||||
v-loading="loading"
|
|
||||||
element-loading-text="库存列表加载中"
|
|
||||||
>
|
|
||||||
<template v-if="items.length === 0 && !loading">
|
|
||||||
<el-empty
|
|
||||||
description="当前筛选下没有库存项,可以先在上方录入内部履约 SKU 对应的库存。"
|
|
||||||
:image-size="60"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<AdminInventoryPoolGrid
|
|
||||||
:inventory-groups="inventoryGroups"
|
|
||||||
:page-overview="pageOverview"
|
|
||||||
:page-summary="pageSummary"
|
|
||||||
:use-group-as-filter="useGroupAsFilter"
|
|
||||||
:use-group-as-entry="useGroupAsEntry"
|
|
||||||
:get-credential-type-label="getCredentialTypeLabel"
|
|
||||||
:get-group-suggestion="getGroupSuggestion"
|
|
||||||
:format-admin-date-time="formatAdminDateTime"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminInventoryTable
|
|
||||||
:loading="loading"
|
|
||||||
:items="items"
|
|
||||||
:pagination="pagination"
|
|
||||||
:page-summary="pageSummary"
|
|
||||||
:action-loading-id="actionLoadingId"
|
|
||||||
:load-inventory-items="loadInventoryItems"
|
|
||||||
:run-row-action="runRowAction"
|
|
||||||
:format-inventory-status="formatInventoryStatus"
|
|
||||||
:get-credential-type-label="getCredentialTypeLabel"
|
|
||||||
:format-admin-date-time="formatAdminDateTime"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import '../../../styles/admin-list-pages.css';
|
|
||||||
</style>
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { adminInventoryCredentialTypeOptions } from '@/utils/admin-options'
|
|
||||||
|
|
||||||
type SkuSuggestion = {
|
|
||||||
credentialType: string
|
|
||||||
inventoryGroupCode: string
|
|
||||||
skuCode: string
|
|
||||||
availableCount: number
|
|
||||||
totalCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const entrySkuCode = defineModel<string>('entrySkuCode', { required: true })
|
|
||||||
const entryCredentialType = defineModel<string>('entryCredentialType', { required: true })
|
|
||||||
const entryBatchNo = defineModel<string>('entryBatchNo', { required: true })
|
|
||||||
const entryInventoryGroupCode = defineModel<string>('entryInventoryGroupCode', { required: true })
|
|
||||||
const singleDisplayValue = defineModel<string>('singleDisplayValue', { required: true })
|
|
||||||
const bulkText = defineModel<string>('bulkText', { required: true })
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
creating: boolean
|
|
||||||
importing: boolean
|
|
||||||
skuSuggestionsLoading: boolean
|
|
||||||
entrySkuSuggestions: SkuSuggestion[]
|
|
||||||
submitCreate: () => Promise<void>
|
|
||||||
submitImport: () => Promise<void>
|
|
||||||
applySkuSuggestion: (skuCode: string) => void
|
|
||||||
getCredentialTypeLabel: (value: string) => string
|
|
||||||
getCredentialTypeHint: (value: string) => string
|
|
||||||
getSkuNamingHint: () => string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function querySkuSuggestions(query: string, callback: (items: SkuSuggestion[]) => void) {
|
|
||||||
const keyword = query.trim().toLowerCase()
|
|
||||||
const items = keyword
|
|
||||||
? props.entrySkuSuggestions.filter((item) => item.skuCode.toLowerCase().includes(keyword))
|
|
||||||
: props.entrySkuSuggestions
|
|
||||||
|
|
||||||
callback(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSkuSuggestionSelect(item: Record<string, unknown>) {
|
|
||||||
const skuCode = typeof item.skuCode === 'string' ? item.skuCode : ''
|
|
||||||
if (skuCode) {
|
|
||||||
props.applySkuSuggestion(skuCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-title">入库与导入</span>
|
|
||||||
<span class="card-desc"
|
|
||||||
>填写内部履约 SKU,不是平台侧 sku_code。建议同类商品长期复用同一个内部名称。</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<el-autocomplete
|
|
||||||
v-model="entrySkuCode"
|
|
||||||
class="sku-input-wrap"
|
|
||||||
value-key="skuCode"
|
|
||||||
:fetch-suggestions="querySkuSuggestions"
|
|
||||||
placeholder="内部履约 SKU,可选已有同类型,也可自定义新的中文名"
|
|
||||||
clearable
|
|
||||||
@select="handleSkuSuggestionSelect"
|
|
||||||
>
|
|
||||||
<template #default="{ item }">
|
|
||||||
<div class="autocomplete-option">
|
|
||||||
<strong>{{ item.skuCode }}</strong>
|
|
||||||
<span>
|
|
||||||
{{ item.inventoryGroupCode || '未分组' }} · 可用 {{ item.availableCount }} / 共
|
|
||||||
{{ item.totalCount }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-autocomplete>
|
|
||||||
<el-select v-model="entryCredentialType" placeholder="凭据类型">
|
|
||||||
<el-option
|
|
||||||
v-for="opt in adminInventoryCredentialTypeOptions.filter((item) => item.value)"
|
|
||||||
:key="opt.value"
|
|
||||||
:label="opt.label"
|
|
||||||
:value="opt.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-input v-model="entryBatchNo" placeholder="批次号,可选" clearable />
|
|
||||||
<el-input v-model="entryInventoryGroupCode" placeholder="库存组,例如 A组 / B组" clearable />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sku-helper-bar">
|
|
||||||
<span class="helper-text">{{ getSkuNamingHint() }}</span>
|
|
||||||
<span v-if="skuSuggestionsLoading" class="helper-text">正在加载同类型内部 SKU...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="entrySkuSuggestions.length > 0" class="sku-chip-row">
|
|
||||||
<el-button
|
|
||||||
v-for="suggestion in entrySkuSuggestions"
|
|
||||||
:key="`${suggestion.credentialType}-${suggestion.inventoryGroupCode}-${suggestion.skuCode}-chip`"
|
|
||||||
class="sku-chip"
|
|
||||||
plain
|
|
||||||
@click="applySkuSuggestion(suggestion.skuCode)"
|
|
||||||
>
|
|
||||||
<span class="sku-chip-title">{{ suggestion.skuCode }}</span>
|
|
||||||
<span class="sku-chip-meta"
|
|
||||||
>{{ suggestion.inventoryGroupCode || '未分组' }} · 可用 {{ suggestion.availableCount }} /
|
|
||||||
共 {{ suggestion.totalCount }}</span
|
|
||||||
>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="helper-grid">
|
|
||||||
<div class="helper-item">
|
|
||||||
<span class="helper-title">当前默认匹配</span>
|
|
||||||
<span class="helper-copy">{{ getCredentialTypeLabel(entryCredentialType) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="helper-item">
|
|
||||||
<span class="helper-title">库存组</span>
|
|
||||||
<span class="helper-copy">{{ entryInventoryGroupCode || '未分组,默认全局可用' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="helper-item helper-item-wide">
|
|
||||||
<span class="helper-title">说明</span>
|
|
||||||
<span class="helper-copy">{{ getCredentialTypeHint(entryCredentialType) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="single-row">
|
|
||||||
<el-input
|
|
||||||
v-model="singleDisplayValue"
|
|
||||||
placeholder="单条新增的凭据内容"
|
|
||||||
@keyup.enter="submitCreate"
|
|
||||||
/>
|
|
||||||
<el-button :loading="creating" round type="success" @click="submitCreate">单条新增</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-input
|
|
||||||
v-model="bulkText"
|
|
||||||
type="textarea"
|
|
||||||
:rows="8"
|
|
||||||
placeholder="批量导入支持两种格式: 1. 每行一个凭据内容(使用上方默认内部 SKU / 凭据类型 / 批次 / 库存组) 2. 每行一条:内部SKU|凭据类型|批次号|库存组|凭据内容"
|
|
||||||
class="bulk-input"
|
|
||||||
/>
|
|
||||||
<p class="helper-text helper-text--spaced">
|
|
||||||
示例:sjz_hdl_test01|claim_code|sjz-20260412|A组|DJQFf7HgXXXXXXXXXX
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="action-row">
|
|
||||||
<el-button :loading="importing" round type="primary" @click="submitImport"
|
|
||||||
>批量导入</el-button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import '../../../../styles/admin-list-pages.css';
|
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.3fr 0.95fr 0.85fr 1fr 1fr;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sku-input-wrap {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-option {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-option span {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.single-row,
|
|
||||||
.action-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
.single-row :deep(.el-input) {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helper-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helper-item {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(248, 250, 252, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.helper-title {
|
|
||||||
color: #2559a7;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.helper-copy {
|
|
||||||
min-width: 0;
|
|
||||||
color: #334155;
|
|
||||||
line-height: 1.6;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sku-helper-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: center;
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
|
||||||
.helper-text {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helper-text--spaced {
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-input {
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sku-chip-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sku-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
.sku-chip-title {
|
|
||||||
min-width: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.sku-chip-meta {
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.form-grid {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.form-grid,
|
|
||||||
.helper-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.form-grid,
|
|
||||||
.helper-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.single-row,
|
|
||||||
.action-row,
|
|
||||||
.sku-helper-bar {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
adminInventoryCredentialTypeOptions,
|
|
||||||
adminInventoryStatusOptions,
|
|
||||||
} from '@/utils/admin-options'
|
|
||||||
|
|
||||||
const filterSkuCode = defineModel<string>('filterSkuCode', { required: true })
|
|
||||||
const filterCredentialType = defineModel<string>('filterCredentialType', { required: true })
|
|
||||||
const filterStatus = defineModel<string>('filterStatus', { required: true })
|
|
||||||
const filterBatchNo = defineModel<string>('filterBatchNo', { required: true })
|
|
||||||
const filterInventoryGroupCode = defineModel<string>('filterInventoryGroupCode', { required: true })
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
loadInventoryItems: (page?: number) => Promise<void>
|
|
||||||
resetFilters: () => void
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-title">查询库存池</span>
|
|
||||||
<span class="card-desc">按内部履约 SKU、凭据类型、批次和状态快速定位库存。</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="form-grid">
|
|
||||||
<el-input
|
|
||||||
v-model="filterSkuCode"
|
|
||||||
placeholder="内部履约 SKU,例如 sjz_hdl_test01"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="loadInventoryItems(1)"
|
|
||||||
/>
|
|
||||||
<el-select v-model="filterCredentialType" placeholder="凭据类型" clearable>
|
|
||||||
<el-option
|
|
||||||
v-for="opt in adminInventoryCredentialTypeOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:label="opt.label"
|
|
||||||
:value="opt.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-select v-model="filterStatus" placeholder="状态" clearable>
|
|
||||||
<el-option
|
|
||||||
v-for="opt in adminInventoryStatusOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:label="opt.label"
|
|
||||||
:value="opt.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-input
|
|
||||||
v-model="filterBatchNo"
|
|
||||||
placeholder="批次号"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="loadInventoryItems(1)"
|
|
||||||
/>
|
|
||||||
<el-input
|
|
||||||
v-model="filterInventoryGroupCode"
|
|
||||||
placeholder="库存组,例如 A组"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="loadInventoryItems(1)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="action-row">
|
|
||||||
<el-button round type="primary" @click="loadInventoryItems(1)">查询列表</el-button>
|
|
||||||
<el-button round @click="resetFilters">清空筛选</el-button>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import '../../../../styles/admin-list-pages.css';
|
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.3fr 0.95fr 0.85fr 1fr 1fr;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.form-grid {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.form-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.form-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.action-row {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { InventoryPoolGroup } from '../composables/types'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
inventoryGroups: InventoryPoolGroup[]
|
|
||||||
pageOverview: { skuCount: number; groupCount: number }
|
|
||||||
pageSummary: {
|
|
||||||
availableCount: number
|
|
||||||
reservedCount: number
|
|
||||||
deliveredCount: number
|
|
||||||
invalidCount: number
|
|
||||||
}
|
|
||||||
useGroupAsFilter: (group: InventoryPoolGroup) => void
|
|
||||||
useGroupAsEntry: (group: InventoryPoolGroup) => void
|
|
||||||
getCredentialTypeLabel: (value: string) => string
|
|
||||||
getGroupSuggestion: (group: InventoryPoolGroup) => string
|
|
||||||
formatAdminDateTime: (value: string) => string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="summary-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">当前页内部 SKU</span>
|
|
||||||
<strong class="stat-value">{{ pageOverview.skuCount }}</strong>
|
|
||||||
<span class="stat-meta">当前页去重后的内部履约 SKU 数量</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">当前页库存池</span>
|
|
||||||
<strong class="stat-value">{{ pageOverview.groupCount }}</strong>
|
|
||||||
<span class="stat-meta">按 内部履约 SKU + 凭据类型 聚合后的池子数量</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">可立即分配</span>
|
|
||||||
<strong class="stat-value">{{ pageSummary.availableCount }}</strong>
|
|
||||||
<span class="stat-meta">当前页还能直接被任务预占的库存项</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">预占中</span>
|
|
||||||
<strong class="stat-value">{{ pageSummary.reservedCount }}</strong>
|
|
||||||
<span class="stat-meta">当前页已经被任务锁定的库存项</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pool-section">
|
|
||||||
<div class="card-header pool-section-header">
|
|
||||||
<span class="card-title">当前页库存池概览</span>
|
|
||||||
<span class="card-desc"
|
|
||||||
>先看哪组内部履约 SKU 还有量,再决定补库存、查任务,还是切去人工履约。</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="pool-grid">
|
|
||||||
<el-card v-for="group in inventoryGroups" :key="group.key" shadow="never" class="pool-card">
|
|
||||||
<div class="pool-topline">
|
|
||||||
<div class="pool-title-block">
|
|
||||||
<h4 class="pool-title">{{ group.skuCode }}</h4>
|
|
||||||
<span class="pool-subtitle"
|
|
||||||
>{{ getCredentialTypeLabel(group.credentialType) }} · {{ group.credentialType }} ·
|
|
||||||
{{ group.inventoryGroupCode || '未分组' }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<span class="pool-total">共 {{ group.totalCount }} 条</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pool-stats">
|
|
||||||
<span class="pool-stat">可用 {{ group.availableCount }}</span>
|
|
||||||
<span class="pool-stat">预占 {{ group.reservedCount }}</span>
|
|
||||||
<span class="pool-stat">发放 {{ group.deliveredCount }}</span>
|
|
||||||
<span class="pool-stat">作废 {{ group.invalidCount }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pool-meta">
|
|
||||||
<span>批次 {{ group.batchSummary }}</span>
|
|
||||||
<span>最近更新 {{ formatAdminDateTime(group.latestUpdatedAt) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="pool-suggestion">{{ getGroupSuggestion(group) }}</p>
|
|
||||||
|
|
||||||
<div class="pool-actions">
|
|
||||||
<el-button round @click="useGroupAsFilter(group)">只看这个池</el-button>
|
|
||||||
<el-button round type="primary" @click="useGroupAsEntry(group)">作为入库目标</el-button>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.summary-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(248, 250, 252, 0.72);
|
|
||||||
}
|
|
||||||
.stat-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.stat-value {
|
|
||||||
font-size: 30px;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: block;
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
.stat-meta {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
display: block;
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pool-section {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pool-section-header {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pool-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pool-card {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-4);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
||||||
background: rgba(248, 250, 252, 0.68);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pool-topline {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.pool-title-block {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.pool-title {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.pool-subtitle {
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.pool-total {
|
|
||||||
display: inline-flex;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 30px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background: rgba(37, 89, 167, 0.08);
|
|
||||||
border: 1px solid rgba(37, 89, 167, 0.12);
|
|
||||||
color: #2559a7;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.pool-stats {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.pool-stat {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 30px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background: rgba(37, 89, 167, 0.08);
|
|
||||||
color: #1d4f91;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.pool-meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.pool-suggestion {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.pool-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.summary-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.summary-grid,
|
|
||||||
.pool-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.summary-grid,
|
|
||||||
.pool-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
|
||||||
import type { AdminInventoryItemListItem } from '@/types/admin'
|
|
||||||
import { hasAdminRole } from '@/utils/admin-auth'
|
|
||||||
|
|
||||||
import { invalidateAdminInventoryItem, releaseAdminInventoryItem } from '@/services/admin'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
loading: boolean
|
|
||||||
items: AdminInventoryItemListItem[]
|
|
||||||
pagination: { page: number; pageSize: number; total: number }
|
|
||||||
pageSummary: { availableCount: number; reservedCount: number; deliveredCount: number; invalidCount: number }
|
|
||||||
actionLoadingId: number | null
|
|
||||||
loadInventoryItems: (page?: number) => Promise<void>
|
|
||||||
runRowAction: (id: number, action: () => Promise<unknown>, msg: string, confirm: string) => Promise<void>
|
|
||||||
formatInventoryStatus: (item: AdminInventoryItemListItem) => string
|
|
||||||
getCredentialTypeLabel: (value: string) => string
|
|
||||||
formatAdminDateTime: (value: string) => string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="table-toolbar">
|
|
||||||
当前筛选共 {{ pagination.total }} 条,本页可用 {{ pageSummary.availableCount }} 条,预占
|
|
||||||
{{ pageSummary.reservedCount }} 条,已发放 {{ pageSummary.deliveredCount }} 条,作废
|
|
||||||
{{ pageSummary.invalidCount }} 条。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table :data="items" stripe size="small" class="inventory-table data-table">
|
|
||||||
<el-table-column label="库存项" min-width="200">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="item-cell">
|
|
||||||
<RouterLink
|
|
||||||
v-if="row.reservedByTaskNo"
|
|
||||||
:to="`/admin/tasks/${row.reservedByTaskNo}`"
|
|
||||||
class="item-title"
|
|
||||||
>#{{ row.inventoryItemId }}</RouterLink
|
|
||||||
>
|
|
||||||
<span v-else class="item-title">#{{ row.inventoryItemId }}</span>
|
|
||||||
<code class="item-value">{{ row.displayValue }}</code>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="内部履约 SKU" min-width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="sku-cell">
|
|
||||||
<span class="sku-title">{{ row.skuCode }}</span>
|
|
||||||
<span class="sku-subline">{{ getCredentialTypeLabel(row.credentialType) }}</span>
|
|
||||||
<span class="sku-subline sku-raw">{{ row.credentialType || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="批次 / 库存组" min-width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="info-cell">
|
|
||||||
<span class="info-subline">批次 {{ row.batchNo || '未分批' }}</span>
|
|
||||||
<span class="info-subline">库存组 {{ row.inventoryGroupCode || '未分组' }}</span>
|
|
||||||
<span class="status-copy">{{ formatInventoryStatus(row) }}</span>
|
|
||||||
<span v-if="row.invalidReason" class="status-warning"
|
|
||||||
>原因:{{ row.invalidReason }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态 / 绑定" min-width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="status-stack">
|
|
||||||
<AdminStatusTag :status="row.status" />
|
|
||||||
<AdminStatusTag :status="row.systemBindingStatus" />
|
|
||||||
<AdminStatusTag :status="row.userBindingStatus" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="关联任务 / 订单" min-width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="info-cell">
|
|
||||||
<span class="link-title">{{ row.reservedByTaskNo || '-' }}</span>
|
|
||||||
<span class="link-subline">任务号</span>
|
|
||||||
<span class="link-title">{{ row.platformOrderId || '-' }}</span>
|
|
||||||
<span class="link-subline">平台订单号</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="时间" min-width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="info-cell">
|
|
||||||
<span class="time-line">入库 {{ formatAdminDateTime(row.createdAt) }}</span>
|
|
||||||
<span class="time-line">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
|
|
||||||
<span v-if="row.deliveredAt" class="time-line"
|
|
||||||
>发放 {{ formatAdminDateTime(row.deliveredAt) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="120" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="action-stack">
|
|
||||||
<el-button
|
|
||||||
v-if="hasAdminRole('admin') && row.status === 'reserved'"
|
|
||||||
:loading="actionLoadingId === row.inventoryItemId"
|
|
||||||
size="small"
|
|
||||||
@click="
|
|
||||||
runRowAction(
|
|
||||||
row.inventoryItemId,
|
|
||||||
() => releaseAdminInventoryItem(row.inventoryItemId),
|
|
||||||
'库存项已释放',
|
|
||||||
`确认释放库存项 #${row.inventoryItemId} 吗?释放后任务需要重新分配库存。`,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>释放</el-button
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
v-if="hasAdminRole('admin') && row.status === 'available'"
|
|
||||||
:loading="actionLoadingId === row.inventoryItemId"
|
|
||||||
size="small"
|
|
||||||
type="danger"
|
|
||||||
plain
|
|
||||||
@click="
|
|
||||||
runRowAction(
|
|
||||||
row.inventoryItemId,
|
|
||||||
() =>
|
|
||||||
invalidateAdminInventoryItem(row.inventoryItemId, {
|
|
||||||
reason: '后台手动作废',
|
|
||||||
}),
|
|
||||||
'库存项已作废',
|
|
||||||
`确认作废库存项 #${row.inventoryItemId} 吗?作废后不会再参与自动分配。`,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>作废</el-button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-pagination
|
|
||||||
v-if="pagination.total > 0"
|
|
||||||
v-model:current-page="pagination.page"
|
|
||||||
:page-size="pagination.pageSize"
|
|
||||||
:total="pagination.total"
|
|
||||||
layout="total, prev, pager, next"
|
|
||||||
class="pagination-center"
|
|
||||||
@current-change="loadInventoryItems"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import '../../../../styles/admin-list-pages.css';
|
|
||||||
|
|
||||||
.table-toolbar {
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
padding-top: var(--space-2);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
line-height: 1.6;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-cell,
|
|
||||||
.sku-cell,
|
|
||||||
.info-cell {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-title,
|
|
||||||
.sku-title,
|
|
||||||
.link-title {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-value {
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: 100%;
|
|
||||||
width: fit-content;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: rgba(15, 23, 42, 0.06);
|
|
||||||
color: #0f172a;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-subline,
|
|
||||||
.sku-subline,
|
|
||||||
.link-subline,
|
|
||||||
.time-line,
|
|
||||||
.status-copy {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sku-raw {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-stack,
|
|
||||||
.action-stack {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.action-stack {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-warning {
|
|
||||||
color: #b54708;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.status-stack,
|
|
||||||
.action-stack {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export type InventoryImportRow = {
|
|
||||||
skuCode: string
|
|
||||||
credentialType: string
|
|
||||||
batchNo: string
|
|
||||||
inventoryGroupCode: string
|
|
||||||
displayValue: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InventoryPoolGroup = {
|
|
||||||
key: string
|
|
||||||
skuCode: string
|
|
||||||
credentialType: string
|
|
||||||
inventoryGroupCode: string
|
|
||||||
totalCount: number
|
|
||||||
availableCount: number
|
|
||||||
reservedCount: number
|
|
||||||
deliveredCount: number
|
|
||||||
invalidCount: number
|
|
||||||
latestUpdatedAt: string
|
|
||||||
batchSummary: string
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
|
|
||||||
import {
|
|
||||||
createAdminInventoryItem,
|
|
||||||
fetchAdminInventoryItems,
|
|
||||||
fetchAdminInventorySkuSuggestions,
|
|
||||||
importAdminInventoryItems,
|
|
||||||
} from '@/services/admin'
|
|
||||||
import type {
|
|
||||||
AdminInventoryItemListItem,
|
|
||||||
AdminInventorySkuSuggestion,
|
|
||||||
AdminPagination,
|
|
||||||
} from '@/types/admin'
|
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
|
||||||
import {
|
|
||||||
adminInventoryCredentialTypeOptions,
|
|
||||||
adminInventoryStatusOptions,
|
|
||||||
} from '@/utils/admin-options'
|
|
||||||
|
|
||||||
import type { InventoryImportRow, InventoryPoolGroup } from './types'
|
|
||||||
|
|
||||||
export function useAdminInventory() {
|
|
||||||
const loading = ref(true)
|
|
||||||
const importing = ref(false)
|
|
||||||
const creating = ref(false)
|
|
||||||
const actionLoadingId = ref<number | null>(null)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
|
|
||||||
const filterSkuCode = ref('')
|
|
||||||
const filterCredentialType = ref('')
|
|
||||||
const filterStatus = ref('')
|
|
||||||
const filterBatchNo = ref('')
|
|
||||||
const filterInventoryGroupCode = ref('')
|
|
||||||
|
|
||||||
const entrySkuCode = ref('')
|
|
||||||
const entryCredentialType = ref('claim_code')
|
|
||||||
const entryBatchNo = ref('')
|
|
||||||
const entryInventoryGroupCode = ref('')
|
|
||||||
const singleDisplayValue = ref('')
|
|
||||||
const bulkText = ref('')
|
|
||||||
const skuSuggestionsLoading = ref(false)
|
|
||||||
const inventorySkuSuggestions = ref<AdminInventorySkuSuggestion[]>([])
|
|
||||||
|
|
||||||
const items = ref<AdminInventoryItemListItem[]>([])
|
|
||||||
const pagination = ref<AdminPagination>({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
total: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const pageSummary = computed(() => {
|
|
||||||
const availableCount = items.value.filter((item) => item.status === 'available').length
|
|
||||||
const reservedCount = items.value.filter((item) => item.status === 'reserved').length
|
|
||||||
const deliveredCount = items.value.filter((item) => item.status === 'consumed').length
|
|
||||||
const invalidCount = items.value.filter((item) => item.status === 'invalid').length
|
|
||||||
return { availableCount, reservedCount, deliveredCount, invalidCount }
|
|
||||||
})
|
|
||||||
|
|
||||||
const inventoryGroups = computed<InventoryPoolGroup[]>(() => {
|
|
||||||
const groupMap = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
skuCode: string
|
|
||||||
credentialType: string
|
|
||||||
inventoryGroupCode: string
|
|
||||||
totalCount: number
|
|
||||||
availableCount: number
|
|
||||||
reservedCount: number
|
|
||||||
deliveredCount: number
|
|
||||||
invalidCount: number
|
|
||||||
latestUpdatedAt: string
|
|
||||||
batches: Set<string>
|
|
||||||
}
|
|
||||||
>()
|
|
||||||
|
|
||||||
for (const item of items.value) {
|
|
||||||
const key = `${item.skuCode}::${item.credentialType || 'claim_code'}::${item.inventoryGroupCode || ''}`
|
|
||||||
const current = groupMap.get(key) || {
|
|
||||||
skuCode: item.skuCode,
|
|
||||||
credentialType: item.credentialType || 'claim_code',
|
|
||||||
inventoryGroupCode: item.inventoryGroupCode || '',
|
|
||||||
totalCount: 0,
|
|
||||||
availableCount: 0,
|
|
||||||
reservedCount: 0,
|
|
||||||
deliveredCount: 0,
|
|
||||||
invalidCount: 0,
|
|
||||||
latestUpdatedAt: item.updatedAt,
|
|
||||||
batches: new Set<string>(),
|
|
||||||
}
|
|
||||||
|
|
||||||
current.totalCount += 1
|
|
||||||
if (item.status === 'available') current.availableCount += 1
|
|
||||||
else if (item.status === 'reserved') current.reservedCount += 1
|
|
||||||
else if (item.status === 'consumed') current.deliveredCount += 1
|
|
||||||
else if (item.status === 'invalid') current.invalidCount += 1
|
|
||||||
|
|
||||||
if (item.batchNo) current.batches.add(item.batchNo)
|
|
||||||
if (String(item.updatedAt || '') > String(current.latestUpdatedAt || ''))
|
|
||||||
current.latestUpdatedAt = item.updatedAt
|
|
||||||
groupMap.set(key, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(groupMap.entries())
|
|
||||||
.map(([key, value]) => ({
|
|
||||||
key,
|
|
||||||
skuCode: value.skuCode,
|
|
||||||
credentialType: value.credentialType,
|
|
||||||
inventoryGroupCode: value.inventoryGroupCode,
|
|
||||||
totalCount: value.totalCount,
|
|
||||||
availableCount: value.availableCount,
|
|
||||||
reservedCount: value.reservedCount,
|
|
||||||
deliveredCount: value.deliveredCount,
|
|
||||||
invalidCount: value.invalidCount,
|
|
||||||
latestUpdatedAt: value.latestUpdatedAt,
|
|
||||||
batchSummary:
|
|
||||||
value.batches.size > 0 ? Array.from(value.batches).slice(0, 3).join(' / ') : '未分批',
|
|
||||||
}))
|
|
||||||
.sort((left, right) => {
|
|
||||||
if (right.availableCount !== left.availableCount)
|
|
||||||
return right.availableCount - left.availableCount
|
|
||||||
if (right.reservedCount !== left.reservedCount)
|
|
||||||
return right.reservedCount - left.reservedCount
|
|
||||||
return left.skuCode.localeCompare(right.skuCode)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const pageOverview = computed(() => ({
|
|
||||||
skuCount: new Set(items.value.map((item) => item.skuCode)).size,
|
|
||||||
groupCount: inventoryGroups.value.length,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const entrySkuSuggestions = computed(() => {
|
|
||||||
const keyword = entrySkuCode.value.trim().toLowerCase()
|
|
||||||
const suggestions = inventorySkuSuggestions.value.filter(
|
|
||||||
(item) => item.credentialType === entryCredentialType.value,
|
|
||||||
)
|
|
||||||
if (!keyword) return suggestions.slice(0, 12)
|
|
||||||
return suggestions.filter((item) => item.skuCode.toLowerCase().includes(keyword)).slice(0, 12)
|
|
||||||
})
|
|
||||||
|
|
||||||
function getCredentialTypeLabel(value: string) {
|
|
||||||
const normalized = String(value || '').trim()
|
|
||||||
const option = adminInventoryCredentialTypeOptions.find((item) => item.value === normalized)
|
|
||||||
return option?.label || normalized || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCredentialTypeHint(value: string) {
|
|
||||||
const normalized = String(value || '').trim()
|
|
||||||
if (normalized === 'claim_code')
|
|
||||||
return '当前领取链路会优先预占这一类兑换码,快手云履约等场景可按内部 SKU 统一匹配。'
|
|
||||||
if (normalized === 'card_password') return '适合卡号 / 卡密一体的库存项。'
|
|
||||||
if (normalized === 'account_password') return '适合账号密码、账号令牌等组合凭据。'
|
|
||||||
if (normalized === 'activation_link') return '适合直接发链接的激活类库存。'
|
|
||||||
if (normalized === 'text_credential') return '适合任意纯文本凭据。'
|
|
||||||
return '库存预占时会按凭据类型与内部履约 SKU 一起匹配。'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSkuNamingHint() {
|
|
||||||
if (entrySkuSuggestions.value.length > 0)
|
|
||||||
return `当前凭据类型下已有 ${entrySkuSuggestions.value.length} 个可复用的内部 SKU,也可以直接输入新的中文或英文名称。`
|
|
||||||
return '内部 SKU 支持中文、英文、数字和常见连接符。建议同一类商品长期使用同一个命名,方便共用库存池。'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInventoryStatus(item: AdminInventoryItemListItem) {
|
|
||||||
if (item.status === 'consumed') return '已完成发放'
|
|
||||||
if (item.status === 'reserved') return '已被任务预占'
|
|
||||||
if (item.status === 'invalid') return '已手动作废'
|
|
||||||
return '可参与后续自动分配'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGroupSuggestion(group: InventoryPoolGroup) {
|
|
||||||
if (group.availableCount > 0)
|
|
||||||
return `当前还有 ${group.availableCount} 条可直接分配的库存,可以继续承接订单。`
|
|
||||||
if (group.reservedCount > 0)
|
|
||||||
return `当前没有可用库存,但有 ${group.reservedCount} 条正在被任务预占,建议优先检查任务进度。`
|
|
||||||
if (group.deliveredCount > 0 && group.invalidCount === 0)
|
|
||||||
return '当前页看到的库存都已经发放完成,若继续售卖需要补新的库存项。'
|
|
||||||
if (group.invalidCount > 0 && group.totalCount === group.invalidCount)
|
|
||||||
return '当前页这组库存都已作废,建议重新补充可用库存。'
|
|
||||||
return '当前页没有可立即分配的库存,建议补库存或检查对应履约任务。'
|
|
||||||
}
|
|
||||||
|
|
||||||
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} / ${group.inventoryGroupCode || '未分组'} 填入入库表单`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySkuSuggestion(skuCode: string) {
|
|
||||||
entrySkuCode.value = String(skuCode || '').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildImportRows() {
|
|
||||||
const lines = bulkText.value
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
const rows: InventoryImportRow[] = []
|
|
||||||
const defaultSkuCode = entrySkuCode.value.trim()
|
|
||||||
const defaultCredentialType = entryCredentialType.value.trim() || 'claim_code'
|
|
||||||
const defaultBatchNo = entryBatchNo.value.trim()
|
|
||||||
|
|
||||||
for (let index = 0; index < lines.length; index += 1) {
|
|
||||||
const line = lines[index]
|
|
||||||
if (line.includes('|')) {
|
|
||||||
const parts = line.split('|').map((part) => part.trim())
|
|
||||||
if (parts.length !== 4 && parts.length !== 5)
|
|
||||||
throw new Error(
|
|
||||||
`第 ${index + 1} 行格式不正确,请使用 内部SKU|凭据类型|批次号|库存组|凭据内容`,
|
|
||||||
)
|
|
||||||
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 或凭据内容`)
|
|
||||||
rows.push({
|
|
||||||
skuCode,
|
|
||||||
credentialType: credentialType || defaultCredentialType,
|
|
||||||
batchNo,
|
|
||||||
inventoryGroupCode: inventoryGroupCode || entryInventoryGroupCode.value.trim(),
|
|
||||||
displayValue,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!defaultSkuCode)
|
|
||||||
throw new Error('存在未带内部 SKU 的行,请先填写默认内部履约 SKU,或改用四列导入格式')
|
|
||||||
rows.push({
|
|
||||||
skuCode: defaultSkuCode,
|
|
||||||
credentialType: defaultCredentialType,
|
|
||||||
batchNo: defaultBatchNo,
|
|
||||||
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
|
|
||||||
displayValue: line,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadInventoryItems(page = pagination.value.page) {
|
|
||||||
loading.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
try {
|
|
||||||
const response = await fetchAdminInventoryItems({
|
|
||||||
page,
|
|
||||||
pageSize: pagination.value.pageSize,
|
|
||||||
skuCode: filterSkuCode.value.trim(),
|
|
||||||
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
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error instanceof Error ? error.message : '读取库存列表失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadInventorySkuSuggestions() {
|
|
||||||
skuSuggestionsLoading.value = true
|
|
||||||
try {
|
|
||||||
const response = await fetchAdminInventorySkuSuggestions({
|
|
||||||
credentialType: entryCredentialType.value.trim() || 'claim_code',
|
|
||||||
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
|
|
||||||
limit: 50,
|
|
||||||
})
|
|
||||||
inventorySkuSuggestions.value = response.data.items
|
|
||||||
} catch (error) {
|
|
||||||
showError(error instanceof Error ? error.message : '读取内部 SKU 建议失败')
|
|
||||||
} finally {
|
|
||||||
skuSuggestionsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
filterSkuCode.value = ''
|
|
||||||
filterCredentialType.value = ''
|
|
||||||
filterStatus.value = ''
|
|
||||||
filterBatchNo.value = ''
|
|
||||||
filterInventoryGroupCode.value = ''
|
|
||||||
void loadInventoryItems(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCreate() {
|
|
||||||
if (!entrySkuCode.value.trim() || !singleDisplayValue.value.trim()) {
|
|
||||||
showError('请先填写内部履约 SKU 和凭据内容')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
await createAdminInventoryItem({
|
|
||||||
skuCode: entrySkuCode.value.trim(),
|
|
||||||
credentialType: entryCredentialType.value.trim() || 'claim_code',
|
|
||||||
batchNo: entryBatchNo.value.trim(),
|
|
||||||
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
|
|
||||||
displayValue: singleDisplayValue.value.trim(),
|
|
||||||
})
|
|
||||||
showSuccess('库存项已新增')
|
|
||||||
singleDisplayValue.value = ''
|
|
||||||
await loadInventoryItems(1)
|
|
||||||
} catch (error) {
|
|
||||||
showError(error instanceof Error ? error.message : '新增库存项失败')
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitImport() {
|
|
||||||
const rawLines = bulkText.value
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
if (rawLines.length === 0) {
|
|
||||||
showError('请至少输入一条待导入库存')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
importing.value = true
|
|
||||||
try {
|
|
||||||
const rows = buildImportRows()
|
|
||||||
const response = await importAdminInventoryItems({ rows })
|
|
||||||
showSuccess(`导入完成,新增 ${response.data.created} 条,重复 ${response.data.duplicated} 条`)
|
|
||||||
bulkText.value = ''
|
|
||||||
await loadInventoryItems(1)
|
|
||||||
} catch (error) {
|
|
||||||
showError(error instanceof Error ? error.message : '导入失败')
|
|
||||||
} finally {
|
|
||||||
importing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRowAction(
|
|
||||||
inventoryItemId: number,
|
|
||||||
action: () => Promise<unknown>,
|
|
||||||
successMessage: string,
|
|
||||||
confirmText: string,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await showConfirm(confirmText, '确认操作', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '继续执行',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
actionLoadingId.value = inventoryItemId
|
|
||||||
try {
|
|
||||||
await action()
|
|
||||||
showSuccess(successMessage)
|
|
||||||
await loadInventoryItems()
|
|
||||||
} catch (error) {
|
|
||||||
showError(error instanceof Error ? error.message : '操作失败')
|
|
||||||
} finally {
|
|
||||||
actionLoadingId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(entryCredentialType, () => {
|
|
||||||
void loadInventorySkuSuggestions()
|
|
||||||
})
|
|
||||||
watch(entryInventoryGroupCode, () => {
|
|
||||||
void loadInventorySkuSuggestions()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([loadInventoryItems(), loadInventorySkuSuggestions()])
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
// state
|
|
||||||
loading,
|
|
||||||
importing,
|
|
||||||
creating,
|
|
||||||
actionLoadingId,
|
|
||||||
errorMessage,
|
|
||||||
items,
|
|
||||||
pagination,
|
|
||||||
pageSummary,
|
|
||||||
pageOverview,
|
|
||||||
inventoryGroups,
|
|
||||||
// filter
|
|
||||||
filterSkuCode,
|
|
||||||
filterCredentialType,
|
|
||||||
filterStatus,
|
|
||||||
filterBatchNo,
|
|
||||||
filterInventoryGroupCode,
|
|
||||||
// entry
|
|
||||||
entrySkuCode,
|
|
||||||
entryCredentialType,
|
|
||||||
entryBatchNo,
|
|
||||||
entryInventoryGroupCode,
|
|
||||||
singleDisplayValue,
|
|
||||||
bulkText,
|
|
||||||
skuSuggestionsLoading,
|
|
||||||
inventorySkuSuggestions,
|
|
||||||
entrySkuSuggestions,
|
|
||||||
// actions
|
|
||||||
loadInventoryItems,
|
|
||||||
resetFilters,
|
|
||||||
submitCreate,
|
|
||||||
submitImport,
|
|
||||||
runRowAction,
|
|
||||||
useGroupAsFilter,
|
|
||||||
useGroupAsEntry,
|
|
||||||
applySkuSuggestion,
|
|
||||||
// formatters
|
|
||||||
getCredentialTypeLabel,
|
|
||||||
getCredentialTypeHint,
|
|
||||||
getSkuNamingHint,
|
|
||||||
formatInventoryStatus,
|
|
||||||
getGroupSuggestion,
|
|
||||||
// utils
|
|
||||||
formatAdminDateTime,
|
|
||||||
adminInventoryCredentialTypeOptions,
|
|
||||||
adminInventoryStatusOptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
Box,
|
|
||||||
Monitor,
|
Monitor,
|
||||||
Connection,
|
|
||||||
DataAnalysis,
|
DataAnalysis,
|
||||||
Document,
|
Document,
|
||||||
Expand,
|
Expand,
|
||||||
@@ -35,7 +33,6 @@ const isSidebarCollapsed = ref(
|
|||||||
|
|
||||||
const currentRole = computed(() => getAdminRole())
|
const currentRole = computed(() => getAdminRole())
|
||||||
const isAdmin = computed(() => currentRole.value === 'admin')
|
const isAdmin = computed(() => currentRole.value === 'admin')
|
||||||
const isOperator = computed(() => currentRole.value === 'operator')
|
|
||||||
|
|
||||||
const roleLabel = computed(() => {
|
const roleLabel = computed(() => {
|
||||||
if (currentRole.value === 'admin') return '管理员'
|
if (currentRole.value === 'admin') return '管理员'
|
||||||
@@ -57,14 +54,6 @@ const navItems = computed(() => {
|
|||||||
{ to: '/admin/tasks', label: '任务', icon: List },
|
{ to: '/admin/tasks', label: '任务', icon: List },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isAdmin.value || isOperator.value) {
|
|
||||||
items.push({ to: '/admin/inventory', label: '库存', icon: Box })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAdmin.value || isOperator.value) {
|
|
||||||
items.push({ to: '/admin/webhook-events', label: 'Webhook', icon: Connection })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
items.push(
|
items.push(
|
||||||
{ to: '/admin/platform-shops', label: '平台配置', icon: Setting },
|
{ to: '/admin/platform-shops', label: '平台配置', icon: Setting },
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||||
import { fetchAdminOrderDetail } from '@/services/admin'
|
import { fetchAdminOrderDetail } from '@/services/admin'
|
||||||
import type { AdminOrderDetail } from '@/types/admin'
|
import type { AdminOrderDetail } from '@/types/admin'
|
||||||
import {
|
|
||||||
formatBindingRoleSummary,
|
|
||||||
formatBindingCountSummary,
|
|
||||||
} from '@/utils/admin-display'
|
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
import { formatAdminWebhookEventType } from '@/utils/admin-webhook'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -104,15 +99,6 @@ function formatSpecSummary(spec: Record<string, unknown>) {
|
|||||||
系统绑定</small
|
系统绑定</small
|
||||||
>
|
>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card shadow="never" class="info-tile">
|
|
||||||
<span>库存绑定</span>
|
|
||||||
<strong>{{ detail.order.bindingSummary.totalBindingCount }} 条绑定记录</strong>
|
|
||||||
<small
|
|
||||||
>预占 {{ detail.order.bindingSummary.reservedBindingCount }} · 已消费
|
|
||||||
{{ detail.order.bindingSummary.consumedBindingCount }} · 已释放
|
|
||||||
{{ detail.order.bindingSummary.releasedBindingCount }}</small
|
|
||||||
>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="[detail.order]" size="small" :show-header="false">
|
<el-table :data="[detail.order]" size="small" :show-header="false">
|
||||||
@@ -176,14 +162,6 @@ function formatSpecSummary(spec: Record<string, unknown>) {
|
|||||||
<AdminStatusTag :status="row.userBindingStatus" />
|
<AdminStatusTag :status="row.userBindingStatus" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="库存绑定">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div>{{ formatBindingCountSummary(row.bindingSummary) }}</div>
|
|
||||||
<div class="binding-copy">
|
|
||||||
角色 {{ formatBindingRoleSummary(row.bindingSummary.roleKeys) }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="登录方式">
|
<el-table-column label="登录方式">
|
||||||
<template #default="{ row }">{{ row.loginType || '-' }}</template>
|
<template #default="{ row }">{{ row.loginType || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -193,43 +171,6 @@ function formatSpecSummary(spec: Record<string, unknown>) {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<h3>Webhook 摘要</h3>
|
|
||||||
<el-table :data="detail.webhookEvents" size="small">
|
|
||||||
<el-table-column label="事件 ID">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<RouterLink :to="`/admin/webhook-events/${row.eventId}`">{{
|
|
||||||
row.eventId
|
|
||||||
}}</RouterLink>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="类型">
|
|
||||||
<template #default="{ row }">{{ formatAdminWebhookEventType(row.eventType) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="平台 / 店铺">
|
|
||||||
<template #default="{ row }"
|
|
||||||
>{{ row.platform }} / {{ row.shopName || row.shopId || '-' }}</template
|
|
||||||
>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="验签">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<AdminStatusTag :status="row.signatureValid ? 'success' : 'failed'" />
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="处理">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<AdminStatusTag :status="row.processed ? 'success' : 'failed'" />
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="时间" width="180">
|
|
||||||
<template #default="{ row }">{{ formatAdminDateTime(row.createdAt) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<template #empty>
|
|
||||||
<el-empty description="当前订单还没有关联的 webhook 记录" />
|
|
||||||
</template>
|
|
||||||
</el-table>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="section-card">
|
<el-card shadow="never" class="section-card">
|
||||||
<h3>原始载荷</h3>
|
<h3>原始载荷</h3>
|
||||||
<details class="payload-box">
|
<details class="payload-box">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
closeAdminTask,
|
closeAdminTask,
|
||||||
markAdminTaskManualReview,
|
markAdminTaskManualReview,
|
||||||
regenerateAdminTaskClaimLink,
|
regenerateAdminTaskClaimLink,
|
||||||
releaseAdminTaskInventoryBinding,
|
|
||||||
retryAdminTask,
|
retryAdminTask,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ import AdminTaskActionBar from './components/AdminTaskActionBar.vue'
|
|||||||
import AdminTaskEventsSection from './components/AdminTaskEventsSection.vue'
|
import AdminTaskEventsSection from './components/AdminTaskEventsSection.vue'
|
||||||
import AdminTaskHeroSection from './components/AdminTaskHeroSection.vue'
|
import AdminTaskHeroSection from './components/AdminTaskHeroSection.vue'
|
||||||
import AdminTaskInfoSection from './components/AdminTaskInfoSection.vue'
|
import AdminTaskInfoSection from './components/AdminTaskInfoSection.vue'
|
||||||
import AdminTaskInventoryBindingsSection from './components/AdminTaskInventoryBindingsSection.vue'
|
|
||||||
import AdminTaskManualDispatchSection from './components/AdminTaskManualDispatchSection.vue'
|
import AdminTaskManualDispatchSection from './components/AdminTaskManualDispatchSection.vue'
|
||||||
import AdminTaskRedeemResolutionSection from './components/AdminTaskRedeemResolutionSection.vue'
|
import AdminTaskRedeemResolutionSection from './components/AdminTaskRedeemResolutionSection.vue'
|
||||||
import AdminTaskScreenshotSection from './components/AdminTaskScreenshotSection.vue'
|
import AdminTaskScreenshotSection from './components/AdminTaskScreenshotSection.vue'
|
||||||
@@ -135,20 +133,6 @@ onMounted(loadDetail)
|
|||||||
|
|
||||||
<AdminTaskRedeemResolutionSection v-if="detail.redeemResolution" :detail="detail" />
|
<AdminTaskRedeemResolutionSection v-if="detail.redeemResolution" :detail="detail" />
|
||||||
|
|
||||||
<AdminTaskInventoryBindingsSection
|
|
||||||
v-if="!isKuaishouCloudTask"
|
|
||||||
:detail="detail"
|
|
||||||
:can-view-sensitive-task-data="canViewSensitiveTaskData"
|
|
||||||
:action-loading="actionLoading"
|
|
||||||
@release-binding="
|
|
||||||
runAction(
|
|
||||||
() => releaseAdminTaskInventoryBinding(detail!.task.taskId, $event),
|
|
||||||
'库存绑定已释放',
|
|
||||||
`确认释放该库存绑定吗?`,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminTaskManualDispatchSection
|
<AdminTaskManualDispatchSection
|
||||||
v-if="detail.task.executorKey === 'manual_dispatch'"
|
v-if="detail.task.executorKey === 'manual_dispatch'"
|
||||||
:detail="detail"
|
:detail="detail"
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ import {
|
|||||||
} from "@/services/admin";
|
} from "@/services/admin";
|
||||||
import type { AdminTaskListItem } from "@/types/admin";
|
import type { AdminTaskListItem } from "@/types/admin";
|
||||||
import { hasAdminRole } from "@/utils/admin-auth";
|
import { hasAdminRole } from "@/utils/admin-auth";
|
||||||
import {
|
|
||||||
formatBindingRoleSummary,
|
|
||||||
formatBindingCountSummary,
|
|
||||||
} from "@/utils/admin-display";
|
|
||||||
import { adminTaskStatusOptions } from "@/utils/admin-options";
|
import { adminTaskStatusOptions } from "@/utils/admin-options";
|
||||||
import { formatAdminDateTime } from "@/utils/admin-time";
|
import { formatAdminDateTime } from "@/utils/admin-time";
|
||||||
|
|
||||||
@@ -140,7 +136,7 @@ onMounted(() => {
|
|||||||
<div class="tasks-page list-page">
|
<div class="tasks-page list-page">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
title="交付任务"
|
title="交付任务"
|
||||||
description="更紧凑地查看任务状态、角色绑定、库存凭据和异常信息。"
|
description="更紧凑地查看任务状态、角色绑定和异常信息。"
|
||||||
>
|
>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<span class="total-badge">共 {{ pagination.total }} 条任务</span>
|
<span class="total-badge">共 {{ pagination.total }} 条任务</span>
|
||||||
@@ -292,7 +288,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="角色 / 凭据" min-width="180">
|
<el-table-column label="角色信息" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="cell-stack">
|
<div class="cell-stack">
|
||||||
<span
|
<span
|
||||||
@@ -306,32 +302,6 @@ onMounted(() => {
|
|||||||
<span class="cell-subline"
|
<span class="cell-subline"
|
||||||
>登录 {{ row.loginType || "-" }}</span
|
>登录 {{ row.loginType || "-" }}</span
|
||||||
>
|
>
|
||||||
<span
|
|
||||||
class="cell-subline"
|
|
||||||
:title="row.inventoryDisplayMasked || '-'"
|
|
||||||
>凭据 {{ row.inventoryDisplayMasked || "-" }}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="cell-subline"
|
|
||||||
:title="
|
|
||||||
formatBindingRoleSummary(
|
|
||||||
row.bindingSummary.roleKeys,
|
|
||||||
'无绑定角色'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>绑定
|
|
||||||
{{
|
|
||||||
formatBindingRoleSummary(
|
|
||||||
row.bindingSummary.roleKeys,
|
|
||||||
"无绑定角色"
|
|
||||||
)
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="cell-subline cell-subline--strong"
|
|
||||||
:title="formatBindingCountSummary(row.bindingSummary)"
|
|
||||||
>{{ formatBindingCountSummary(row.bindingSummary) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -367,11 +337,7 @@ onMounted(() => {
|
|||||||
v-if="canManageTaskLifecycle"
|
v-if="canManageTaskLifecycle"
|
||||||
:disabled="
|
:disabled="
|
||||||
row.executorKey === 'manual_dispatch' ||
|
row.executorKey === 'manual_dispatch' ||
|
||||||
![
|
!['retry_pending', 'manual_review'].includes(row.status)
|
||||||
'retry_pending',
|
|
||||||
'manual_review',
|
|
||||||
'waiting_inventory',
|
|
||||||
].includes(row.status)
|
|
||||||
"
|
"
|
||||||
:loading="actionLoadingId === row.taskId"
|
:loading="actionLoadingId === row.taskId"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -36,21 +36,12 @@ defineProps<Props>()
|
|||||||
{{ detail.task.resultMessage || '-' }}
|
{{ detail.task.resultMessage || '-' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<template v-if="canViewSensitiveTaskData">
|
<template v-if="canViewSensitiveTaskData">
|
||||||
<el-descriptions-item label="库存凭据">
|
|
||||||
{{ detail.inventory?.displayValue || '-' }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="凭据类型">
|
|
||||||
{{ detail.inventory?.credentialType || '-' }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="库存状态">
|
|
||||||
<AdminStatusTag :status="detail.inventory?.status || ''" />
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="Claim Token">
|
<el-descriptions-item label="Claim Token">
|
||||||
{{ detail.claimToken?.token || '-' }}
|
{{ detail.claimToken?.token || '-' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</template>
|
</template>
|
||||||
<el-descriptions-item v-else label="库存 / Token" :span="2">
|
<el-descriptions-item v-else label="Claim Token" :span="2">
|
||||||
客服账号不可查看库存凭据与原始领取 Token
|
客服账号不可查看原始领取 Token
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="Token 状态">
|
<el-descriptions-item label="Token 状态">
|
||||||
<AdminStatusTag :status="detail.claimToken?.status || ''" />
|
<AdminStatusTag :status="detail.claimToken?.status || ''" />
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
|
||||||
import type { AdminTaskDetail } from '@/types/admin'
|
|
||||||
import { hasAdminRole } from '@/utils/admin-auth'
|
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
detail: AdminTaskDetail
|
|
||||||
canViewSensitiveTaskData: boolean
|
|
||||||
actionLoading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
releaseBinding: [bindingId: number]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<h3>库存绑定</h3>
|
|
||||||
<p class="section-copy">
|
|
||||||
这里展示任务当前和历史绑定过的库存项,便于核对多库存任务的真实履约轨迹。
|
|
||||||
</p>
|
|
||||||
<el-table :data="detail.inventoryBindings" size="small">
|
|
||||||
<el-table-column label="绑定" width="100">
|
|
||||||
<template #default="{ row: binding }">
|
|
||||||
<div>#{{ binding.bindingId }}</div>
|
|
||||||
<div v-if="binding.isPrimary" class="binding-primary">主库存项</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="角色" min-width="120">
|
|
||||||
<template #default="{ row: binding }">
|
|
||||||
<div>{{ binding.roleKey || '-' }}</div>
|
|
||||||
<div class="binding-meta">数量 {{ binding.quantity }}</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="库存项" min-width="180">
|
|
||||||
<template #default="{ row: binding }">
|
|
||||||
<div>ID {{ binding.inventoryItemId }}</div>
|
|
||||||
<div>{{ binding.skuCode || '-' }}</div>
|
|
||||||
<div class="binding-meta">
|
|
||||||
{{ binding.credentialType || '-' }} ·
|
|
||||||
{{ binding.batchNo || '无批次' }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" min-width="160">
|
|
||||||
<template #default="{ row: binding }">
|
|
||||||
<div class="status-stack">
|
|
||||||
<AdminStatusTag :status="binding.bindingStatus" />
|
|
||||||
<AdminStatusTag :status="binding.inventoryStatus" />
|
|
||||||
</div>
|
|
||||||
<div v-if="binding.invalidReason" class="binding-meta">
|
|
||||||
{{
|
|
||||||
binding.inventoryStatus === 'invalid'
|
|
||||||
? '作废原因'
|
|
||||||
: binding.inventoryStatus === 'consumed'
|
|
||||||
? '消耗原因'
|
|
||||||
: '处理原因'
|
|
||||||
}}:{{ binding.invalidReason }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="凭据内容" min-width="180">
|
|
||||||
<template #default="{ row: binding }">
|
|
||||||
{{ canViewSensitiveTaskData ? binding.displayValue || '-' : '客服不可见' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="时间" min-width="180">
|
|
||||||
<template #default="{ row: binding }">
|
|
||||||
<div>绑定 {{ formatAdminDateTime(binding.createdAt) }}</div>
|
|
||||||
<div class="binding-meta">消费 {{ formatAdminDateTime(binding.consumedAt) }}</div>
|
|
||||||
<div class="binding-meta">释放 {{ formatAdminDateTime(binding.releasedAt) }}</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="100">
|
|
||||||
<template #default="{ row: binding }">
|
|
||||||
<el-button
|
|
||||||
v-if="hasAdminRole('admin') && binding.canRelease"
|
|
||||||
:loading="actionLoading"
|
|
||||||
link
|
|
||||||
@click="emit('releaseBinding', binding.bindingId)"
|
|
||||||
>
|
|
||||||
释放绑定
|
|
||||||
</el-button>
|
|
||||||
<span v-else class="binding-meta">-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<template #empty>
|
|
||||||
<el-empty description="当前任务还没有库存绑定记录" :image-size="60" />
|
|
||||||
</template>
|
|
||||||
</el-table>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped src="../AdminTaskDetail.css"></style>
|
|
||||||
@@ -40,7 +40,7 @@ defineProps<Props>()
|
|||||||
<el-card
|
<el-card
|
||||||
shadow="never"
|
shadow="never"
|
||||||
v-for="attempt in detail.redeemResolution!.attempts"
|
v-for="attempt in detail.redeemResolution!.attempts"
|
||||||
:key="`${attempt.attempt}-${attempt.inventoryItemId || 'na'}`"
|
:key="attempt.attempt"
|
||||||
class="redeem-attempt-card"
|
class="redeem-attempt-card"
|
||||||
>
|
>
|
||||||
<div class="redeem-attempt-head">
|
<div class="redeem-attempt-head">
|
||||||
@@ -48,10 +48,6 @@ defineProps<Props>()
|
|||||||
<span class="redeem-outcome">{{ formatRedeemOutcomeLabel(attempt.outcome) }}</span>
|
<span class="redeem-outcome">{{ formatRedeemOutcomeLabel(attempt.outcome) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="redeem-attempt-grid">
|
<div class="redeem-attempt-grid">
|
||||||
<div>
|
|
||||||
<span>库存项</span>
|
|
||||||
<strong>{{ attempt.inventoryItemId ? `#${attempt.inventoryItemId}` : '-' }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<span>凭据</span>
|
<span>凭据</span>
|
||||||
<strong>{{ attempt.codeMasked || '-' }}</strong>
|
<strong>{{ attempt.codeMasked || '-' }}</strong>
|
||||||
|
|||||||
@@ -26,11 +26,9 @@ const {
|
|||||||
updateRole,
|
updateRole,
|
||||||
toggleStatus,
|
toggleStatus,
|
||||||
promptResetPassword,
|
promptResetPassword,
|
||||||
promptUpdateInventoryGroups,
|
|
||||||
// formatters
|
// formatters
|
||||||
isCurrentUser,
|
isCurrentUser,
|
||||||
formatRoleLabel,
|
formatRoleLabel,
|
||||||
formatInventoryGroups,
|
|
||||||
// utils
|
// utils
|
||||||
hasAdminRole,
|
hasAdminRole,
|
||||||
formatAdminDateTime,
|
formatAdminDateTime,
|
||||||
@@ -78,9 +76,7 @@ const {
|
|||||||
:update-role="updateRole"
|
:update-role="updateRole"
|
||||||
:toggle-status="toggleStatus"
|
:toggle-status="toggleStatus"
|
||||||
:prompt-reset-password="promptResetPassword"
|
:prompt-reset-password="promptResetPassword"
|
||||||
:prompt-update-inventory-groups="promptUpdateInventoryGroups"
|
|
||||||
:format-role-label="formatRoleLabel"
|
:format-role-label="formatRoleLabel"
|
||||||
:format-inventory-groups="formatInventoryGroups"
|
|
||||||
:format-admin-date-time="formatAdminDateTime"
|
:format-admin-date-time="formatAdminDateTime"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ defineProps<{
|
|||||||
updateRole: (item: AdminUserListItem, nextRole: AdminRole) => Promise<void>
|
updateRole: (item: AdminUserListItem, nextRole: AdminRole) => Promise<void>
|
||||||
toggleStatus: (item: AdminUserListItem) => Promise<void>
|
toggleStatus: (item: AdminUserListItem) => Promise<void>
|
||||||
promptResetPassword: (item: AdminUserListItem) => Promise<void>
|
promptResetPassword: (item: AdminUserListItem) => Promise<void>
|
||||||
promptUpdateInventoryGroups: (item: AdminUserListItem) => Promise<void>
|
|
||||||
formatRoleLabel: (role: AdminRole) => string
|
formatRoleLabel: (role: AdminRole) => string
|
||||||
formatInventoryGroups: (item: AdminUserListItem) => string[]
|
|
||||||
formatAdminDateTime: (value: string) => string
|
formatAdminDateTime: (value: string) => string
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@@ -39,19 +37,6 @@ defineProps<{
|
|||||||
<el-table-column label="角色" width="82">
|
<el-table-column label="角色" width="82">
|
||||||
<template #default="{ row }">{{ formatRoleLabel(row.role) }}</template>
|
<template #default="{ row }">{{ formatRoleLabel(row.role) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="库存组" width="112">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="inventory-tags">
|
|
||||||
<el-tag
|
|
||||||
v-for="(code, i) in formatInventoryGroups(row)"
|
|
||||||
:key="i"
|
|
||||||
size="small"
|
|
||||||
:type="code === '未绑定' || code === '不限库存组' ? 'info' : undefined"
|
|
||||||
>{{ code }}</el-tag
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" width="128">
|
<el-table-column label="状态" width="128">
|
||||||
<template #default="{ row }"><AdminStatusTag :status="row.status" /></template>
|
<template #default="{ row }"><AdminStatusTag :status="row.status" /></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -101,13 +86,6 @@ defineProps<{
|
|||||||
@click="toggleStatus(row)"
|
@click="toggleStatus(row)"
|
||||||
>{{ row.status === 'active' ? '停用' : '启用' }}</el-button
|
>{{ row.status === 'active' ? '停用' : '启用' }}</el-button
|
||||||
>
|
>
|
||||||
<el-button
|
|
||||||
:loading="actionLoadingId === row.userId"
|
|
||||||
size="small"
|
|
||||||
:disabled="row.role !== 'support'"
|
|
||||||
@click="promptUpdateInventoryGroups(row)"
|
|
||||||
>配库存组</el-button
|
|
||||||
>
|
|
||||||
<el-button
|
<el-button
|
||||||
:loading="actionLoadingId === row.userId"
|
:loading="actionLoadingId === row.userId"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -163,15 +141,6 @@ defineProps<{
|
|||||||
.users-table :deep(.el-table__body tr:hover > td.el-table__cell) {
|
.users-table :deep(.el-table__body tr:hover > td.el-table__cell) {
|
||||||
background: var(--el-table-row-hover-bg-color);
|
background: var(--el-table-row-hover-bg-color);
|
||||||
}
|
}
|
||||||
.inventory-tags {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.inventory-tags :deep(.el-tag) {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.date-cell {
|
.date-cell {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
createAdminUser,
|
createAdminUser,
|
||||||
fetchAdminUsers,
|
fetchAdminUsers,
|
||||||
resetAdminUserPassword,
|
resetAdminUserPassword,
|
||||||
updateAdminUserInventoryGroups,
|
|
||||||
updateAdminUserRole,
|
updateAdminUserRole,
|
||||||
updateAdminUserStatus,
|
updateAdminUserStatus,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
@@ -44,12 +43,6 @@ export function useAdminUsers() {
|
|||||||
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
|
||||||
@@ -170,39 +163,6 @@ export function useAdminUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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((v) => v.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)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -227,11 +187,9 @@ export function useAdminUsers() {
|
|||||||
updateRole,
|
updateRole,
|
||||||
toggleStatus,
|
toggleStatus,
|
||||||
promptResetPassword,
|
promptResetPassword,
|
||||||
promptUpdateInventoryGroups,
|
|
||||||
// formatters
|
// formatters
|
||||||
isCurrentUser,
|
isCurrentUser,
|
||||||
formatRoleLabel,
|
formatRoleLabel,
|
||||||
formatInventoryGroups,
|
|
||||||
// utils
|
// utils
|
||||||
hasAdminRole,
|
hasAdminRole,
|
||||||
formatAdminDateTime,
|
formatAdminDateTime,
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
|
|
||||||
import { fetchAdminWebhookEventDetail, replayAdminWebhookEvent } from '@/services/admin'
|
|
||||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
|
||||||
import type { AdminWebhookEventDetail, AdminWebhookReplayResponse } from '@/types/admin'
|
|
||||||
import { hasAdminRole } from '@/utils/admin-auth'
|
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
|
||||||
import { stringifyDisplayJson } from '@/utils/date-time'
|
|
||||||
import { formatAdminWebhookEventType } from '@/utils/admin-webhook'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const loading = ref(true)
|
|
||||||
const replayLoading = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const detail = ref<AdminWebhookEventDetail | null>(null)
|
|
||||||
const lastReplayResult = ref<AdminWebhookReplayResponse['result'] | null>(null)
|
|
||||||
|
|
||||||
async function loadDetail() {
|
|
||||||
loading.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchAdminWebhookEventDetail(String(route.params.eventId || ''))
|
|
||||||
detail.value = response.data
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error instanceof Error ? error.message : '读取 webhook 详情失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitReplay() {
|
|
||||||
if (!detail.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await showConfirm(
|
|
||||||
`确认重放 webhook #${detail.value.eventId} 吗?这会重新推进订单与任务处理。`,
|
|
||||||
'确认重放',
|
|
||||||
{
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '继续重放',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
replayLoading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await replayAdminWebhookEvent(detail.value.eventId)
|
|
||||||
lastReplayResult.value = response.data.result
|
|
||||||
showSuccess(`Webhook 已重放,生成/更新任务 ${response.data.result.taskCount} 条`)
|
|
||||||
await loadDetail()
|
|
||||||
} catch (error) {
|
|
||||||
showError(error instanceof Error ? error.message : '重放 webhook 失败')
|
|
||||||
} finally {
|
|
||||||
replayLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prettyJson(value: Record<string, unknown>) {
|
|
||||||
return stringifyDisplayJson(value, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRequestTimestamp(value: string) {
|
|
||||||
const normalized = String(value || '').trim()
|
|
||||||
|
|
||||||
if (!/^\d{10,13}$/.test(normalized)) {
|
|
||||||
return normalized || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
const epochMs = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000
|
|
||||||
return formatAdminDateTime(new Date(epochMs).toISOString())
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAmount(detail: AdminWebhookEventDetail) {
|
|
||||||
return detail.totalAmount ? `${detail.totalAmount} ${detail.currency}` : '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadDetail)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="admin-panel">
|
|
||||||
<AdminPageHeader title="Webhook 详情" description="查看原始请求并手动重放处理流程。">
|
|
||||||
<el-button
|
|
||||||
v-if="hasAdminRole('admin')"
|
|
||||||
:loading="replayLoading"
|
|
||||||
round
|
|
||||||
type="primary"
|
|
||||||
@click="submitReplay"
|
|
||||||
>重放处理</el-button
|
|
||||||
>
|
|
||||||
</AdminPageHeader>
|
|
||||||
|
|
||||||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" />
|
|
||||||
<el-skeleton v-if="loading" :rows="8" animated />
|
|
||||||
|
|
||||||
<template v-else-if="detail">
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<div class="overview-top">
|
|
||||||
<div>
|
|
||||||
<h3>{{ formatAdminWebhookEventType(detail.eventType) }}</h3>
|
|
||||||
<p class="muted-copy">
|
|
||||||
#{{ detail.eventId }} · {{ detail.provider }} / {{ detail.platform }}
|
|
||||||
<template v-if="detail.platformRaw && detail.platformRaw !== detail.platform">
|
|
||||||
· 原始平台 {{ detail.platformRaw }}
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="tag-row">
|
|
||||||
<AdminStatusTag :status="detail.signatureValid ? 'success' : 'failed'" />
|
|
||||||
<AdminStatusTag :status="detail.processed ? 'success' : 'failed'" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary-grid">
|
|
||||||
<div class="summary-item">
|
|
||||||
<span class="summary-label">店铺</span>
|
|
||||||
<strong>{{ detail.shopName || detail.shopId || '-' }}</strong>
|
|
||||||
<span class="summary-note">ID: {{ detail.shopId || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item">
|
|
||||||
<span class="summary-label">平台订单</span>
|
|
||||||
<strong>{{ detail.platformOrderId || '-' }}</strong>
|
|
||||||
<span class="summary-note">aopic: {{ detail.aopic || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item">
|
|
||||||
<span class="summary-label">关联订单</span>
|
|
||||||
<strong v-if="detail.relatedOrderId">
|
|
||||||
<RouterLink class="inline-link" :to="`/admin/orders/${detail.relatedOrderId}`">{{
|
|
||||||
detail.relatedOrderId
|
|
||||||
}}</RouterLink>
|
|
||||||
</strong>
|
|
||||||
<strong v-else>-</strong>
|
|
||||||
<span class="summary-note">任务数: {{ detail.taskCount }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item">
|
|
||||||
<span class="summary-label">买家</span>
|
|
||||||
<strong>{{ detail.buyerName || detail.buyerId || '-' }}</strong>
|
|
||||||
<span class="summary-note">买家 ID: {{ detail.buyerId || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item">
|
|
||||||
<span class="summary-label">金额</span>
|
|
||||||
<strong>{{ formatAmount(detail) }}</strong>
|
|
||||||
<span class="summary-note">商品数: {{ detail.itemCount }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item">
|
|
||||||
<span class="summary-label">请求时间</span>
|
|
||||||
<strong>{{ formatRequestTimestamp(detail.requestTimestamp) }}</strong>
|
|
||||||
<span class="summary-note">入库时间: {{ formatAdminDateTime(detail.createdAt) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item">
|
|
||||||
<span class="summary-label">来源 Host</span>
|
|
||||||
<strong>{{ detail.sourceHost || '-' }}</strong>
|
|
||||||
<span class="summary-note">来源 IP: {{ detail.sourceIp || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item">
|
|
||||||
<span class="summary-label">消息说明</span>
|
|
||||||
<strong>{{ detail.messageText || '-' }}</strong>
|
|
||||||
<span class="summary-note">签名: {{ detail.requestSign || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="event-key-block">
|
|
||||||
<span class="summary-label">事件 Key</span>
|
|
||||||
<code>{{ detail.eventKey }}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="detail.processError" class="inline-error">{{ detail.processError }}</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card v-if="lastReplayResult" shadow="never" class="section-card">
|
|
||||||
<h3>最近一次重放结果</h3>
|
|
||||||
<p class="result-copy">
|
|
||||||
{{ lastReplayResult.provider }} / {{ lastReplayResult.platform }}
|
|
||||||
<template
|
|
||||||
v-if="
|
|
||||||
lastReplayResult.platformRaw &&
|
|
||||||
lastReplayResult.platformRaw !== lastReplayResult.platform
|
|
||||||
"
|
|
||||||
>
|
|
||||||
· 原始平台 {{ lastReplayResult.platformRaw }}
|
|
||||||
</template>
|
|
||||||
· 平台订单 {{ lastReplayResult.platformOrderId }} · 事件
|
|
||||||
{{ formatAdminWebhookEventType(lastReplayResult.eventType) }} · 任务数
|
|
||||||
{{ lastReplayResult.taskCount }}
|
|
||||||
</p>
|
|
||||||
<el-table :data="lastReplayResult.tasks" size="small">
|
|
||||||
<el-table-column prop="taskId" label="任务 ID" />
|
|
||||||
<el-table-column prop="taskNo" label="任务号" />
|
|
||||||
<el-table-column label="状态">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<AdminStatusTag :status="row.status" />
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<h3>解析后的 Payload</h3>
|
|
||||||
<pre class="json-block">{{ prettyJson(detail.payload) }}</pre>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<h3>Headers</h3>
|
|
||||||
<pre class="json-block">{{ prettyJson(detail.headers) }}</pre>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<h3>Query</h3>
|
|
||||||
<pre class="json-block">{{ prettyJson(detail.query) }}</pre>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<h3>Body</h3>
|
|
||||||
<pre class="json-block">{{ prettyJson(detail.body) }}</pre>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.admin-panel {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-top {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-top h3,
|
|
||||||
.section-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted-copy {
|
|
||||||
margin: var(--space-2) 0 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-item {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-page);
|
|
||||||
border: 1px solid var(--border-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-item strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-note {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
line-height: 1.5;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-key-block {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-page);
|
|
||||||
border: 1px solid var(--border-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-key-block code {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
line-height: 1.6;
|
|
||||||
word-break: break-all;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-error {
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
padding: var(--space-3) 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
color: var(--color-danger);
|
|
||||||
background: var(--color-danger-light);
|
|
||||||
border: 1px solid #fecdca;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-copy {
|
|
||||||
margin: var(--space-3) 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-link {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-block {
|
|
||||||
margin: var(--space-3) 0 0;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.overview-top {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from "vue";
|
|
||||||
import { useRoute, useRouter } from "vue-router";
|
|
||||||
|
|
||||||
import { useAdminListPage } from "@/composables/useAdminListPage";
|
|
||||||
import { useAdminListQuerySync } from "@/composables/useAdminListQuerySync";
|
|
||||||
import AdminPageHeader from "@/components/admin/AdminPageHeader.vue";
|
|
||||||
import AdminStatusTag from "@/components/admin/AdminStatusTag.vue";
|
|
||||||
import { showConfirm, showError, showSuccess } from "@/lib/feedback";
|
|
||||||
import {
|
|
||||||
fetchAdminWebhookEvents,
|
|
||||||
replayAdminWebhookEvent,
|
|
||||||
} from "@/services/admin";
|
|
||||||
import type { AdminWebhookEventListItem } from "@/types/admin";
|
|
||||||
import { hasAdminRole } from "@/utils/admin-auth";
|
|
||||||
import { formatAdminDateTime } from "@/utils/admin-time";
|
|
||||||
import { formatAdminWebhookEventType } from "@/utils/admin-webhook";
|
|
||||||
import {
|
|
||||||
adminWebhookProcessedOptions,
|
|
||||||
adminWebhookVisibilityOptions,
|
|
||||||
} from "@/utils/admin-options";
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const { readQueryString, readQueryPage, syncRouteQuery } =
|
|
||||||
useAdminListQuerySync(route, router);
|
|
||||||
|
|
||||||
const actionLoadingId = ref<number | null>(null);
|
|
||||||
const provider = ref(readQueryString("provider"));
|
|
||||||
const platform = ref(readQueryString("platform"));
|
|
||||||
const platformOrderId = ref(readQueryString("platformOrderId"));
|
|
||||||
const processed = ref(readQueryString("processed"));
|
|
||||||
const visibility = ref(readQueryString("visibility") || "important");
|
|
||||||
const relatedOrderId = ref(readQueryString("relatedOrderId"));
|
|
||||||
const dateRange = ref<string[]>(
|
|
||||||
[readQueryString("dateFrom"), readQueryString("dateTo")].filter(Boolean)
|
|
||||||
);
|
|
||||||
const canReplay = hasAdminRole("admin");
|
|
||||||
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
errorMessage,
|
|
||||||
items,
|
|
||||||
pagination,
|
|
||||||
loadPage: loadEvents,
|
|
||||||
} = useAdminListPage<AdminWebhookEventListItem>({
|
|
||||||
defaultErrorMessage: "读取 webhook 列表失败",
|
|
||||||
fetchPage: (page, pageSize) =>
|
|
||||||
fetchAdminWebhookEvents({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
provider: provider.value.trim(),
|
|
||||||
platform: platform.value.trim(),
|
|
||||||
platformOrderId: platformOrderId.value.trim(),
|
|
||||||
processed: processed.value.trim(),
|
|
||||||
visibility: visibility.value.trim(),
|
|
||||||
relatedOrderId: relatedOrderId.value.trim(),
|
|
||||||
dateFrom: dateRange.value[0] || "",
|
|
||||||
dateTo: dateRange.value[1] || "",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const summary = computed(() => ({
|
|
||||||
processedCount: items.value.filter((item) => item.processed).length,
|
|
||||||
failedCount: items.value.filter(
|
|
||||||
(item) => !item.processed || Boolean(item.processError)
|
|
||||||
).length,
|
|
||||||
importantCount: items.value.filter(
|
|
||||||
(item) => item.visibilityLevel === "important"
|
|
||||||
).length,
|
|
||||||
}));
|
|
||||||
|
|
||||||
async function submitReplay(item: AdminWebhookEventListItem) {
|
|
||||||
try {
|
|
||||||
await showConfirm(`确认重放 webhook #${item.eventId} 吗?`, "确认重放", {
|
|
||||||
type: "warning",
|
|
||||||
confirmButtonText: "继续重放",
|
|
||||||
cancelButtonText: "取消",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
actionLoadingId.value = item.eventId;
|
|
||||||
try {
|
|
||||||
await replayAdminWebhookEvent(item.eventId);
|
|
||||||
showSuccess("Webhook 已重放");
|
|
||||||
await loadEvents();
|
|
||||||
} catch (error) {
|
|
||||||
showError(error instanceof Error ? error.message : "重放 webhook 失败");
|
|
||||||
} finally {
|
|
||||||
actionLoadingId.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
provider.value = "";
|
|
||||||
platform.value = "";
|
|
||||||
platformOrderId.value = "";
|
|
||||||
processed.value = "";
|
|
||||||
visibility.value = "important";
|
|
||||||
relatedOrderId.value = "";
|
|
||||||
dateRange.value = [];
|
|
||||||
void applyFilters(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyFilters(page = 1) {
|
|
||||||
await syncRouteQuery(page, {
|
|
||||||
provider: provider.value,
|
|
||||||
platform: platform.value,
|
|
||||||
platformOrderId: platformOrderId.value,
|
|
||||||
processed: processed.value,
|
|
||||||
visibility: visibility.value,
|
|
||||||
relatedOrderId: relatedOrderId.value,
|
|
||||||
dateFrom: dateRange.value[0] || "",
|
|
||||||
dateTo: dateRange.value[1] || "",
|
|
||||||
});
|
|
||||||
await loadEvents(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVisibilityLabel(value: string) {
|
|
||||||
return value === "ignored" ? "已忽略" : "重要";
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void applyFilters(readQueryPage());
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="webhooks-page list-page">
|
|
||||||
<AdminPageHeader
|
|
||||||
title="Webhook 日志"
|
|
||||||
description="查看平台推送处理情况、验签结果、请求来源和错误信息。"
|
|
||||||
>
|
|
||||||
<template #extra>
|
|
||||||
<span class="total-badge">共 {{ pagination.total }} 条事件</span>
|
|
||||||
</template>
|
|
||||||
</AdminPageHeader>
|
|
||||||
|
|
||||||
<!-- 筛选 -->
|
|
||||||
<el-card shadow="never" class="section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-title">筛选 Webhook</span>
|
|
||||||
<span class="card-desc"
|
|
||||||
>按服务商、平台、处理状态、可见级别等条件筛选。</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="filter-grid">
|
|
||||||
<el-input
|
|
||||||
v-model="provider"
|
|
||||||
class="filter-control"
|
|
||||||
placeholder="服务商,如 91kaquan"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="applyFilters(1)"
|
|
||||||
/>
|
|
||||||
<el-input
|
|
||||||
v-model="platform"
|
|
||||||
class="filter-control"
|
|
||||||
placeholder="业务平台,如 kuaishou"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="applyFilters(1)"
|
|
||||||
/>
|
|
||||||
<el-select
|
|
||||||
v-model="processed"
|
|
||||||
class="filter-control"
|
|
||||||
placeholder="处理结果"
|
|
||||||
clearable
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="opt in adminWebhookProcessedOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:label="opt.label"
|
|
||||||
:value="opt.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-select
|
|
||||||
v-model="visibility"
|
|
||||||
class="filter-control"
|
|
||||||
placeholder="可见级别"
|
|
||||||
clearable
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="opt in adminWebhookVisibilityOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:label="opt.label"
|
|
||||||
:value="opt.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-input
|
|
||||||
v-model="platformOrderId"
|
|
||||||
class="filter-control"
|
|
||||||
placeholder="平台订单号"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="applyFilters(1)"
|
|
||||||
/>
|
|
||||||
<el-input
|
|
||||||
v-model="relatedOrderId"
|
|
||||||
class="filter-control"
|
|
||||||
placeholder="后台订单 ID"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="applyFilters(1)"
|
|
||||||
/>
|
|
||||||
<el-date-picker
|
|
||||||
v-model="dateRange"
|
|
||||||
class="filter-control"
|
|
||||||
type="daterange"
|
|
||||||
start-placeholder="开始日期"
|
|
||||||
end-placeholder="结束日期"
|
|
||||||
range-separator="至"
|
|
||||||
value-format="YYYY-MM-DD"
|
|
||||||
/>
|
|
||||||
<div class="filter-buttons">
|
|
||||||
<el-button round @click="resetFilters">重置</el-button>
|
|
||||||
<el-button round type="primary" @click="applyFilters(1)"
|
|
||||||
>查询</el-button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<!-- 摘要 -->
|
|
||||||
<div v-if="!loading && items.length > 0" class="summary-strip">
|
|
||||||
<span class="summary-title">当前页</span>
|
|
||||||
<span class="summary-metric" data-tone="success"
|
|
||||||
>处理成功 <strong>{{ summary.processedCount }}</strong></span
|
|
||||||
>
|
|
||||||
<span class="summary-metric" data-tone="danger"
|
|
||||||
>异常/失败 <strong>{{ summary.failedCount }}</strong></span
|
|
||||||
>
|
|
||||||
<span class="summary-metric" data-tone="primary"
|
|
||||||
>重要事件 <strong>{{ summary.importantCount }}</strong></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="errorMessage"
|
|
||||||
:title="errorMessage"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mt-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 列表 -->
|
|
||||||
<el-card
|
|
||||||
shadow="never"
|
|
||||||
class="section-card"
|
|
||||||
v-loading="loading"
|
|
||||||
element-loading-text="Webhook 列表加载中"
|
|
||||||
>
|
|
||||||
<template v-if="items.length === 0 && !loading">
|
|
||||||
<el-empty
|
|
||||||
description="当前筛选条件下暂无 Webhook 事件"
|
|
||||||
:image-size="60"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="orders-toolbar table-toolbar">
|
|
||||||
<strong>Webhook 列表</strong>
|
|
||||||
<span
|
|
||||||
>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table
|
|
||||||
:data="items"
|
|
||||||
stripe
|
|
||||||
size="small"
|
|
||||||
class="orders-table data-table"
|
|
||||||
>
|
|
||||||
<el-table-column label="事件ID" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<RouterLink
|
|
||||||
:to="`/admin/webhook-events/${row.eventId}`"
|
|
||||||
class="order-no id-link"
|
|
||||||
>
|
|
||||||
#{{ row.eventId }}
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="事件类型" min-width="200">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="cell-stack">
|
|
||||||
<span class="cell-title">{{
|
|
||||||
formatAdminWebhookEventType(row.eventType)
|
|
||||||
}}</span>
|
|
||||||
<div class="cell-meta-row">
|
|
||||||
<span class="compact-chip">{{ row.provider || "-" }}</span>
|
|
||||||
<span class="compact-chip">{{ row.platform || "-" }}</span>
|
|
||||||
<span class="compact-chip compact-chip--muted">{{
|
|
||||||
row.shopName || row.shopId || "-"
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<span v-if="row.aopic" class="cell-subline"
|
|
||||||
>aopic {{ row.aopic }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="平台订单号" min-width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="cell-stack">
|
|
||||||
<span class="cell-title">{{ row.platformOrderId || "-" }}</span>
|
|
||||||
<span v-if="row.totalAmount" class="cell-subline"
|
|
||||||
>{{ row.totalAmount }} {{ row.currency }}</span
|
|
||||||
>
|
|
||||||
<span v-if="row.buyerName || row.buyerId" class="cell-subline"
|
|
||||||
>买家 {{ row.buyerName || row.buyerId }}</span
|
|
||||||
>
|
|
||||||
<RouterLink
|
|
||||||
v-if="row.relatedOrderId"
|
|
||||||
class="inline-link"
|
|
||||||
:to="`/admin/orders/${row.relatedOrderId}`"
|
|
||||||
>
|
|
||||||
关联订单 #{{ row.relatedOrderId }}
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" min-width="150">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="cell-stack">
|
|
||||||
<div class="status-stack">
|
|
||||||
<AdminStatusTag
|
|
||||||
:status="row.processed ? 'success' : 'failed'"
|
|
||||||
/>
|
|
||||||
<AdminStatusTag
|
|
||||||
:status="row.signatureValid ? 'success' : 'failed'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="cell-subline">{{
|
|
||||||
formatVisibilityLabel(row.visibilityLevel)
|
|
||||||
}}</span>
|
|
||||||
<span v-if="row.processError" class="error-inline">{{
|
|
||||||
row.processError
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="创建时间" min-width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="cell-stack">
|
|
||||||
<span class="cell-title">{{
|
|
||||||
formatAdminDateTime(row.createdAt)
|
|
||||||
}}</span>
|
|
||||||
<span class="cell-subline">#{{ row.eventId }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="130" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="action-stack">
|
|
||||||
<RouterLink
|
|
||||||
:to="`/admin/webhook-events/${row.eventId}`"
|
|
||||||
class="detail-link"
|
|
||||||
>详情</RouterLink
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
v-if="canReplay"
|
|
||||||
:loading="actionLoadingId === row.eventId"
|
|
||||||
size="small"
|
|
||||||
@click="submitReplay(row)"
|
|
||||||
>重放</el-button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-pagination
|
|
||||||
v-if="pagination.total > 0"
|
|
||||||
v-model:current-page="pagination.page"
|
|
||||||
:page-size="pagination.pageSize"
|
|
||||||
:total="pagination.total"
|
|
||||||
layout="total, prev, pager, next"
|
|
||||||
class="pagination-center"
|
|
||||||
@current-change="applyFilters"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import "../../../styles/admin-list-pages.css";
|
|
||||||
|
|
||||||
.filter-grid {
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-inline {
|
|
||||||
display: block;
|
|
||||||
color: var(--color-danger);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-link {
|
|
||||||
color: var(--color-primary-dark);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.inline-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-stack {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+3
-3
@@ -50,11 +50,11 @@
|
|||||||
- `POST /api/v1/open/91/orders/create`
|
- `POST /api/v1/open/91/orders/create`
|
||||||
- `POST /api/v1/open/91/orders/query`
|
- `POST /api/v1/open/91/orders/query`
|
||||||
|
|
||||||
不放进现有 `/api/v1/webhooks`,因为:
|
接口使用独立公开路由组,原因:
|
||||||
|
|
||||||
- `91` 不是 webhook 来源;
|
|
||||||
- `91` 需要自己的响应结构:`code/message/data`;
|
- `91` 需要自己的响应结构:`code/message/data`;
|
||||||
- 与现有项目 `buildSuccessPayload(code=0)` 不兼容。
|
- 与后台管理接口的统一响应结构隔离;
|
||||||
|
- 便于按 91 的签名、时间戳和错误码规范单独演进。
|
||||||
|
|
||||||
### 4.2 返回格式
|
### 4.2 返回格式
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user