优化接单商品匹配
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
-- 034_worker_product_match_catalog.sql —— 接单模板与快手商品/SKU 映射、匹配日志。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work_product_rule_mappings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
rule_id BIGINT NOT NULL REFERENCES work_product_rules(id) ON DELETE CASCADE,
|
||||||
|
seller_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
rel_item_id TEXT NOT NULL DEFAULT '',
|
||||||
|
item_title TEXT NOT NULL DEFAULT '',
|
||||||
|
rel_sku_id TEXT NOT NULL DEFAULT '',
|
||||||
|
sku_nick TEXT NOT NULL DEFAULT '',
|
||||||
|
mapping_type TEXT NOT NULL DEFAULT 'product_default'
|
||||||
|
CHECK (mapping_type IN ('product_default', 'sku_exact', 'sku_series')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
CHECK (jsonb_typeof(seller_ids_json) = 'array' AND jsonb_array_length(seller_ids_json) > 0),
|
||||||
|
CHECK (mapping_type IN ('sku_exact', 'sku_series') OR rel_item_id <> '' OR item_title <> ''),
|
||||||
|
CHECK (
|
||||||
|
(mapping_type = 'product_default' AND rel_sku_id = '' AND sku_nick = '')
|
||||||
|
OR
|
||||||
|
(mapping_type = 'sku_exact' AND (rel_sku_id <> '' OR sku_nick <> ''))
|
||||||
|
OR
|
||||||
|
(mapping_type = 'sku_series' AND rel_sku_id = '' AND sku_nick <> '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS work_product_rule_mappings_lookup_idx
|
||||||
|
ON work_product_rule_mappings (rel_item_id, mapping_type, enabled);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS work_product_rule_mappings_seller_ids_idx
|
||||||
|
ON work_product_rule_mappings USING GIN (seller_ids_json);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work_product_match_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||||
|
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT '',
|
||||||
|
seller_id TEXT NOT NULL DEFAULT '',
|
||||||
|
rel_item_id TEXT NOT NULL DEFAULT '',
|
||||||
|
item_title TEXT NOT NULL DEFAULT '',
|
||||||
|
rel_sku_id TEXT NOT NULL DEFAULT '',
|
||||||
|
sku_nick TEXT NOT NULL DEFAULT '',
|
||||||
|
match_status TEXT NOT NULL CHECK (match_status IN ('matched', 'unmatched', 'ambiguous')),
|
||||||
|
rule_id BIGINT REFERENCES work_product_rules(id) ON DELETE SET NULL,
|
||||||
|
mapping_id BIGINT REFERENCES work_product_rule_mappings(id) ON DELETE SET NULL,
|
||||||
|
candidates_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS work_product_match_logs_created_idx
|
||||||
|
ON work_product_match_logs (created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS work_product_match_logs_status_idx
|
||||||
|
ON work_product_match_logs (match_status, created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE work_product_rule_mappings IS '快手多店商品、SKU 精确与数量系列到接单模板的确定性映射';
|
||||||
|
COMMENT ON TABLE work_product_match_logs IS '快手接单商品匹配日志与调试载荷快照';
|
||||||
@@ -204,6 +204,7 @@ export async function listKuaishouIndustryVouchersByOid(
|
|||||||
export type KuaishouIndustryVoucherWorkOrderSyncSource = {
|
export type KuaishouIndustryVoucherWorkOrderSyncSource = {
|
||||||
oid: string
|
oid: string
|
||||||
raw_payload_json: string | Record<string, unknown>
|
raw_payload_json: string | Record<string, unknown>
|
||||||
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listKuaishouIndustryVoucherWorkOrderSyncSources(
|
export async function listKuaishouIndustryVoucherWorkOrderSyncSources(
|
||||||
@@ -211,7 +212,7 @@ export async function listKuaishouIndustryVoucherWorkOrderSyncSources(
|
|||||||
): Promise<KuaishouIndustryVoucherWorkOrderSyncSource[]> {
|
): Promise<KuaishouIndustryVoucherWorkOrderSyncSource[]> {
|
||||||
const result = await query<KuaishouIndustryVoucherWorkOrderSyncSource>(
|
const result = await query<KuaishouIndustryVoucherWorkOrderSyncSource>(
|
||||||
`
|
`
|
||||||
SELECT oid, raw_payload_json
|
SELECT oid, raw_payload_json, updated_at
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT ON (oid) oid, raw_payload_json, updated_at, id
|
SELECT DISTINCT ON (oid) oid, raw_payload_json, updated_at, id
|
||||||
FROM kuaishou_industry_vouchers
|
FROM kuaishou_industry_vouchers
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from './types.js'
|
|||||||
export * from './shared.js'
|
export * from './shared.js'
|
||||||
export * from './worker-repo.js'
|
export * from './worker-repo.js'
|
||||||
export * from './work-order-repo.js'
|
export * from './work-order-repo.js'
|
||||||
|
export * from './product-match-repo.js'
|
||||||
export * from './sms-code-repo.js'
|
export * from './sms-code-repo.js'
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { query } from '../../db/client.js'
|
||||||
|
import type { WorkProductMatchLogRow, WorkProductRuleMappingRow } from './types.js'
|
||||||
|
|
||||||
|
const MAPPING_SELECT = `
|
||||||
|
SELECT
|
||||||
|
wprm.*,
|
||||||
|
wpr.rule_key,
|
||||||
|
wpr.product_name
|
||||||
|
FROM work_product_rule_mappings wprm
|
||||||
|
INNER JOIN work_product_rules wpr ON wpr.id = wprm.rule_id
|
||||||
|
`
|
||||||
|
|
||||||
|
export async function listWorkProductRuleMappings(
|
||||||
|
input: {
|
||||||
|
enabled?: boolean | null
|
||||||
|
sellerId?: string
|
||||||
|
} = {},
|
||||||
|
): Promise<WorkProductRuleMappingRow[]> {
|
||||||
|
const conditions: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
if (input.enabled !== undefined && input.enabled !== null) {
|
||||||
|
params.push(input.enabled)
|
||||||
|
conditions.push(`wprm.enabled = $${params.length}`)
|
||||||
|
}
|
||||||
|
const sellerId = String(input.sellerId || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (sellerId) {
|
||||||
|
params.push(sellerId)
|
||||||
|
conditions.push(`wprm.seller_ids_json ? $${params.length}`)
|
||||||
|
}
|
||||||
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||||
|
const result = await query<WorkProductRuleMappingRow>(
|
||||||
|
`${MAPPING_SELECT}
|
||||||
|
${where}
|
||||||
|
ORDER BY wprm.seller_ids_json::text ASC, wprm.rel_item_id ASC, wprm.item_title ASC,
|
||||||
|
CASE WHEN wprm.mapping_type = 'product_default' THEN 0 ELSE 1 END ASC,
|
||||||
|
wprm.id ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertWorkProductRuleMapping(input: {
|
||||||
|
mappingId?: number | null
|
||||||
|
ruleId: number
|
||||||
|
sellerIds: string[]
|
||||||
|
relItemId: string
|
||||||
|
itemTitle: string
|
||||||
|
relSkuId: string
|
||||||
|
skuNick: string
|
||||||
|
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||||
|
enabled: boolean
|
||||||
|
now: string
|
||||||
|
}): Promise<WorkProductRuleMappingRow | null> {
|
||||||
|
if (input.mappingId) {
|
||||||
|
const result = await query<WorkProductRuleMappingRow>(
|
||||||
|
`
|
||||||
|
UPDATE work_product_rule_mappings
|
||||||
|
SET
|
||||||
|
rule_id = $2,
|
||||||
|
seller_ids_json = $3::jsonb,
|
||||||
|
rel_item_id = $4,
|
||||||
|
item_title = $5,
|
||||||
|
rel_sku_id = $6,
|
||||||
|
sku_nick = $7,
|
||||||
|
mapping_type = $8,
|
||||||
|
enabled = $9,
|
||||||
|
updated_at = $10
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.mappingId,
|
||||||
|
input.ruleId,
|
||||||
|
JSON.stringify(input.sellerIds),
|
||||||
|
input.relItemId,
|
||||||
|
input.itemTitle,
|
||||||
|
input.relSkuId,
|
||||||
|
input.skuNick,
|
||||||
|
input.mappingType,
|
||||||
|
input.enabled,
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query<WorkProductRuleMappingRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO work_product_rule_mappings (
|
||||||
|
rule_id, seller_ids_json, rel_item_id, item_title, rel_sku_id, sku_nick,
|
||||||
|
mapping_type, enabled, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.ruleId,
|
||||||
|
JSON.stringify(input.sellerIds),
|
||||||
|
input.relItemId,
|
||||||
|
input.itemTitle,
|
||||||
|
input.relSkuId,
|
||||||
|
input.skuNick,
|
||||||
|
input.mappingType,
|
||||||
|
input.enabled,
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkProductRuleMapping(mappingId: number | string) {
|
||||||
|
const result = await query<{ id: number }>(
|
||||||
|
'DELETE FROM work_product_rule_mappings WHERE id = $1 RETURNING id',
|
||||||
|
[Number(mappingId)],
|
||||||
|
)
|
||||||
|
return { deleted: Boolean(result.rows[0]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkProductMatchLog(input: {
|
||||||
|
orderId?: number | null
|
||||||
|
orderItemId?: number | null
|
||||||
|
source: string
|
||||||
|
sellerId: string
|
||||||
|
relItemId: string
|
||||||
|
itemTitle: string
|
||||||
|
relSkuId: string
|
||||||
|
skuNick: string
|
||||||
|
matchStatus: 'matched' | 'unmatched' | 'ambiguous'
|
||||||
|
ruleId?: number | null
|
||||||
|
mappingId?: number | null
|
||||||
|
candidatesJson: string
|
||||||
|
rawPayloadJson: string
|
||||||
|
now: string
|
||||||
|
}): Promise<WorkProductMatchLogRow | null> {
|
||||||
|
const result = await query<WorkProductMatchLogRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO work_product_match_logs (
|
||||||
|
order_id, order_item_id, source, seller_id, rel_item_id, item_title,
|
||||||
|
rel_sku_id, sku_nick, match_status, rule_id, mapping_id,
|
||||||
|
candidates_json, raw_payload_json, created_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
$7, $8, $9, $10, $11,
|
||||||
|
$12::jsonb, $13::jsonb, $14
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.orderId || null,
|
||||||
|
input.orderItemId || null,
|
||||||
|
input.source,
|
||||||
|
input.sellerId,
|
||||||
|
input.relItemId,
|
||||||
|
input.itemTitle,
|
||||||
|
input.relSkuId,
|
||||||
|
input.skuNick,
|
||||||
|
input.matchStatus,
|
||||||
|
input.ruleId || null,
|
||||||
|
input.mappingId || null,
|
||||||
|
input.candidatesJson,
|
||||||
|
input.rawPayloadJson,
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkProductMatchLogs(limit = 100): Promise<WorkProductMatchLogRow[]> {
|
||||||
|
const result = await query<WorkProductMatchLogRow>(
|
||||||
|
`
|
||||||
|
SELECT wpml.*, wpr.rule_key, wpr.product_name
|
||||||
|
FROM work_product_match_logs wpml
|
||||||
|
LEFT JOIN work_product_rules wpr ON wpr.id = wpml.rule_id
|
||||||
|
ORDER BY wpml.created_at DESC, wpml.id DESC
|
||||||
|
LIMIT $1
|
||||||
|
`,
|
||||||
|
[Math.min(500, Math.max(1, Math.floor(Number(limit) || 100)))],
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
@@ -160,6 +160,42 @@ export type WorkProductRuleRow = {
|
|||||||
category_name?: string
|
category_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkProductRuleMappingRow = {
|
||||||
|
id: number
|
||||||
|
rule_id: number
|
||||||
|
seller_ids_json: string | string[]
|
||||||
|
rel_item_id: string
|
||||||
|
item_title: string
|
||||||
|
rel_sku_id: string
|
||||||
|
sku_nick: string
|
||||||
|
mapping_type: 'product_default' | 'sku_exact' | 'sku_series' | string
|
||||||
|
enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
rule_key?: string
|
||||||
|
product_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkProductMatchLogRow = {
|
||||||
|
id: number
|
||||||
|
order_id: number | null
|
||||||
|
order_item_id: number | null
|
||||||
|
source: string
|
||||||
|
seller_id: string
|
||||||
|
rel_item_id: string
|
||||||
|
item_title: string
|
||||||
|
rel_sku_id: string
|
||||||
|
sku_nick: string
|
||||||
|
match_status: 'matched' | 'unmatched' | 'ambiguous' | string
|
||||||
|
rule_id: number | null
|
||||||
|
mapping_id: number | null
|
||||||
|
candidates_json: string | Record<string, unknown> | unknown[]
|
||||||
|
raw_payload_json: string | Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
rule_key?: string
|
||||||
|
product_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkOrderShareRow = {
|
export type WorkOrderShareRow = {
|
||||||
id: number
|
id: number
|
||||||
work_order_id: number
|
work_order_id: number
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
listAdminWorkerFinanceRequests,
|
listAdminWorkerFinanceRequests,
|
||||||
listAdminWorkerWithdrawalAccounts,
|
listAdminWorkerWithdrawalAccounts,
|
||||||
listAdminWorkCategories,
|
listAdminWorkCategories,
|
||||||
|
listAdminKuaishouMatchSources,
|
||||||
|
listAdminWorkProductMatchLogs,
|
||||||
|
listAdminWorkProductRuleMappings,
|
||||||
listAdminWorkProductRules,
|
listAdminWorkProductRules,
|
||||||
listAdminWorkerLevels,
|
listAdminWorkerLevels,
|
||||||
listAdminWorkerUsers,
|
listAdminWorkerUsers,
|
||||||
@@ -36,12 +39,15 @@ import {
|
|||||||
saveAdminWorkerPlatformNotificationConfig,
|
saveAdminWorkerPlatformNotificationConfig,
|
||||||
saveAdminWorkerWithdrawalAccount,
|
saveAdminWorkerWithdrawalAccount,
|
||||||
saveAdminWorkCategory,
|
saveAdminWorkCategory,
|
||||||
|
saveAdminWorkProductRuleMapping,
|
||||||
saveAdminWorkProductRule,
|
saveAdminWorkProductRule,
|
||||||
|
testAdminKuaishouProductMatch,
|
||||||
saveAdminWorkerLevel,
|
saveAdminWorkerLevel,
|
||||||
submitAdminWorkOrderMaterial,
|
submitAdminWorkOrderMaterial,
|
||||||
unpublishAdminWorkOrder,
|
unpublishAdminWorkOrder,
|
||||||
updateAdminWorkOrder,
|
updateAdminWorkOrder,
|
||||||
updateAdminWorkOrderSharing,
|
updateAdminWorkOrderSharing,
|
||||||
|
deleteAdminWorkProductRuleMapping,
|
||||||
} from '../../services/worker-platform/index.js'
|
} from '../../services/worker-platform/index.js'
|
||||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||||
|
|
||||||
@@ -184,6 +190,75 @@ router.delete(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/worker-platform/product-mappings',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler(() => listAdminWorkProductRuleMappings(), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取商品映射失败',
|
||||||
|
scope: '[admin/worker-platform/product-mappings]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/worker-platform/product-mappings',
|
||||||
|
requireAdminRoles(['admin', 'operator']),
|
||||||
|
createJsonHandler((req) => saveAdminWorkProductRuleMapping(req.body || {}), {
|
||||||
|
successMessage: '商品映射已保存',
|
||||||
|
errorMessage: '保存商品映射失败',
|
||||||
|
scope: '[admin/worker-platform/product-mappings]',
|
||||||
|
audit: (_req, data) => ({
|
||||||
|
action: 'work_product_mapping_saved',
|
||||||
|
targetType: 'work_product_mapping',
|
||||||
|
targetId: String((data as { mapping?: { mappingId?: number } })?.mapping?.mappingId || ''),
|
||||||
|
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/worker-platform/product-mappings/:mappingId',
|
||||||
|
requireAdminRoles(['admin', 'operator']),
|
||||||
|
createJsonHandler(
|
||||||
|
(req) => deleteAdminWorkProductRuleMapping(String(req.params.mappingId || '')),
|
||||||
|
{
|
||||||
|
successMessage: '商品映射已删除',
|
||||||
|
errorMessage: '删除商品映射失败',
|
||||||
|
scope: '[admin/worker-platform/product-mappings/:mappingId]',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/worker-platform/product-match-sources',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler((req) => listAdminKuaishouMatchSources(req.query), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取快手匹配载荷失败',
|
||||||
|
scope: '[admin/worker-platform/product-match-sources]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/worker-platform/product-match-test',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler((req) => testAdminKuaishouProductMatch(req.body || {}), {
|
||||||
|
successMessage: '匹配测试完成',
|
||||||
|
errorMessage: '匹配测试失败',
|
||||||
|
scope: '[admin/worker-platform/product-match-test]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/worker-platform/product-match-logs',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler((req) => listAdminWorkProductMatchLogs(req.query), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取匹配日志失败',
|
||||||
|
scope: '[admin/worker-platform/product-match-logs]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/worker-platform/workers',
|
'/worker-platform/workers',
|
||||||
requireAdminRoles(['admin', 'operator', 'support']),
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||||
|
import { listKuaishouIndustryVoucherWorkOrderSyncSources } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
import {
|
import {
|
||||||
acceptWorkOrderAndSettle,
|
acceptWorkOrderAndSettle,
|
||||||
addWorkerWalletCredit,
|
addWorkerWalletCredit,
|
||||||
@@ -12,10 +13,12 @@ import {
|
|||||||
countWorkProductRulesByCategory,
|
countWorkProductRulesByCategory,
|
||||||
countWorkOrderPendingSharingSubmissions,
|
countWorkOrderPendingSharingSubmissions,
|
||||||
countTimeoutEventsByWorkerIds,
|
countTimeoutEventsByWorkerIds,
|
||||||
|
createWorkProductMatchLog,
|
||||||
createWorkOrder,
|
createWorkOrder,
|
||||||
createWorkOrderEvent,
|
createWorkOrderEvent,
|
||||||
deductPendingDepositUnfreeze,
|
deductPendingDepositUnfreeze,
|
||||||
deleteWorkCategory,
|
deleteWorkCategory,
|
||||||
|
deleteWorkProductRuleMapping,
|
||||||
deleteWorkProductRule,
|
deleteWorkProductRule,
|
||||||
deleteWorkOrder,
|
deleteWorkOrder,
|
||||||
deleteWorkerLevel,
|
deleteWorkerLevel,
|
||||||
@@ -32,6 +35,8 @@ import {
|
|||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
listWorkOrderEventsByOrderId,
|
listWorkOrderEventsByOrderId,
|
||||||
listWorkProductRules,
|
listWorkProductRules,
|
||||||
|
listWorkProductRuleMappings,
|
||||||
|
listWorkProductMatchLogs,
|
||||||
listWorkProductRulesPage,
|
listWorkProductRulesPage,
|
||||||
listWorkerLevels,
|
listWorkerLevels,
|
||||||
listWorkerUsers,
|
listWorkerUsers,
|
||||||
@@ -48,8 +53,11 @@ import {
|
|||||||
upsertWorkerWithdrawalAccount,
|
upsertWorkerWithdrawalAccount,
|
||||||
upsertWorkCategory,
|
upsertWorkCategory,
|
||||||
upsertWorkProductRule,
|
upsertWorkProductRule,
|
||||||
|
upsertWorkProductRuleMapping,
|
||||||
upsertWorkerLevel,
|
upsertWorkerLevel,
|
||||||
type WorkOrderRow,
|
type WorkOrderRow,
|
||||||
|
type WorkProductMatchLogRow,
|
||||||
|
type WorkProductRuleMappingRow,
|
||||||
type WorkOrderShareRow,
|
type WorkOrderShareRow,
|
||||||
type WorkerWithdrawalAccountRow,
|
type WorkerWithdrawalAccountRow,
|
||||||
} from '../../repositories/worker-platform/index.js'
|
} from '../../repositories/worker-platform/index.js'
|
||||||
@@ -118,6 +126,7 @@ import {
|
|||||||
resolveFreezeDepositAmount,
|
resolveFreezeDepositAmount,
|
||||||
mapWorkOrderEvents,
|
mapWorkOrderEvents,
|
||||||
resolveMatchingProductRuleDecision,
|
resolveMatchingProductRuleDecision,
|
||||||
|
resolveKuaishouWorkProductMatchContext,
|
||||||
resolveRequirementFields,
|
resolveRequirementFields,
|
||||||
resolveSkuNameQuantity,
|
resolveSkuNameQuantity,
|
||||||
resolveWorkerPermissions,
|
resolveWorkerPermissions,
|
||||||
@@ -269,6 +278,304 @@ export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObje
|
|||||||
return reprocessKuaishouSendCodeWorkOrders(limit)
|
return reprocessKuaishouSendCodeWorkOrders(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAdminWorkProductRuleMappings() {
|
||||||
|
const items = await listWorkProductRuleMappings()
|
||||||
|
return { items: items.map(mapWorkProductRuleMapping) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {}) {
|
||||||
|
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
|
||||||
|
const sellerIds = normalizeKuaishouMatchIds(
|
||||||
|
payload.sellerIds ?? payload.seller_ids ?? payload.sellerId ?? payload.seller_id,
|
||||||
|
)
|
||||||
|
const relItemIds = normalizeKuaishouMatchIdList(payload.relItemId ?? payload.rel_item_id)
|
||||||
|
const relItemId = relItemIds.join(',')
|
||||||
|
const itemTitle = String(payload.itemTitle ?? payload.item_title ?? '').trim()
|
||||||
|
const rawMappingType = String(payload.mappingType ?? payload.mapping_type ?? '').trim()
|
||||||
|
const mappingType =
|
||||||
|
rawMappingType === 'sku_series'
|
||||||
|
? 'sku_series'
|
||||||
|
: rawMappingType === 'sku_exact' || rawMappingType === 'sku_override'
|
||||||
|
? 'sku_exact'
|
||||||
|
: 'product_default'
|
||||||
|
const relSkuId =
|
||||||
|
mappingType === 'sku_exact'
|
||||||
|
? normalizeKuaishouMatchId(payload.relSkuId ?? payload.rel_sku_id)
|
||||||
|
: ''
|
||||||
|
const skuNick =
|
||||||
|
mappingType === 'product_default'
|
||||||
|
? ''
|
||||||
|
: String(payload.skuNick ?? payload.sku_nick ?? '').trim()
|
||||||
|
const requiresProductScope = mappingType === 'product_default'
|
||||||
|
if (
|
||||||
|
!ruleId ||
|
||||||
|
sellerIds.length === 0 ||
|
||||||
|
(requiresProductScope && relItemIds.length === 0 && !itemTitle)
|
||||||
|
) {
|
||||||
|
throw createHttpError(
|
||||||
|
'请选择接单模板,并填写至少一个店铺;商品默认规则还需填写大标题或关联商品 ID',
|
||||||
|
{
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_product_mapping_identity_required',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
mappingType === 'product_default' &&
|
||||||
|
sellerIds.length > 1 &&
|
||||||
|
(!itemTitle || relItemIds.length > 0)
|
||||||
|
) {
|
||||||
|
throw createHttpError('多店共享规则请填写快手大标题,并留空关联商品 ID', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_product_mapping_multi_shop_scope_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mappingType === 'sku_exact' && !relSkuId && !skuNick) {
|
||||||
|
throw createHttpError('SKU 精确规则需要关联 SKU ID 或具体 SKU 名称', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_product_mapping_sku_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mappingType === 'sku_series' && !skuNick) {
|
||||||
|
throw createHttpError('SKU 数量系列需要填写系列名称', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_product_mapping_series_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const rule = (await listWorkProductRules()).find((item) => Number(item.id) === ruleId)
|
||||||
|
if (!rule) {
|
||||||
|
throw createHttpError('接单模板不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'work_product_mapping_rule_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const mapping = await upsertWorkProductRuleMapping({
|
||||||
|
mappingId: normalizeOptionalId(payload.mappingId ?? payload.mapping_id),
|
||||||
|
ruleId,
|
||||||
|
sellerIds,
|
||||||
|
relItemId,
|
||||||
|
itemTitle,
|
||||||
|
relSkuId,
|
||||||
|
skuNick,
|
||||||
|
mappingType,
|
||||||
|
enabled: normalizeBoolean(payload.enabled, true),
|
||||||
|
now: nowIso(),
|
||||||
|
})
|
||||||
|
if (!mapping) {
|
||||||
|
throw createHttpError('商品映射保存失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'work_product_mapping_save_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mapping: mapWorkProductRuleMapping({
|
||||||
|
...mapping,
|
||||||
|
rule_key: rule.rule_key,
|
||||||
|
product_name: rule.product_name,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminWorkProductRuleMapping(mappingId: number | string) {
|
||||||
|
const normalizedMappingId = normalizeOptionalId(mappingId)
|
||||||
|
if (!normalizedMappingId) {
|
||||||
|
throw createHttpError('商品映射 ID 不正确', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_product_mapping_id_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return deleteWorkProductRuleMapping(normalizedMappingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminKuaishouMatchSources(payload: JsonObject = {}) {
|
||||||
|
const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100))
|
||||||
|
const sources = await listKuaishouIndustryVoucherWorkOrderSyncSources(limit)
|
||||||
|
const grouped = new Map<string, JsonObject>()
|
||||||
|
for (const source of sources) {
|
||||||
|
const rawPayload = safeParseJson(source.raw_payload_json)
|
||||||
|
const body = resolveKuaishouMatchPayload(rawPayload)
|
||||||
|
const ext = safeParseJson(body.ext)
|
||||||
|
const sellerId = normalizeKuaishouMatchId(body.sellerId)
|
||||||
|
const relItemId = normalizeKuaishouMatchId(ext.relItemId)
|
||||||
|
const itemTitle = String(body.itemTitle || '').trim()
|
||||||
|
const relSkuId = normalizeKuaishouMatchId(ext.relSkuId)
|
||||||
|
const skuNick = String(ext.skuNick || '').trim()
|
||||||
|
if (!sellerId || (!relItemId && !itemTitle)) continue
|
||||||
|
const key = [
|
||||||
|
sellerId,
|
||||||
|
relItemId,
|
||||||
|
normalizeMatchTextForAdmin(itemTitle),
|
||||||
|
relSkuId,
|
||||||
|
normalizeMatchTextForAdmin(skuNick),
|
||||||
|
].join('|')
|
||||||
|
const current = grouped.get(key)
|
||||||
|
grouped.set(key, {
|
||||||
|
sellerId,
|
||||||
|
relItemId,
|
||||||
|
itemTitle,
|
||||||
|
relSkuId,
|
||||||
|
skuNick,
|
||||||
|
sampleOid: String(body.oid || source.oid || '').trim(),
|
||||||
|
lastSeenAt: source.updated_at,
|
||||||
|
seenCount: Number(current?.seenCount || 0) + 1,
|
||||||
|
rawPayload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: Array.from(grouped.values()).sort((left, right) =>
|
||||||
|
String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testAdminKuaishouProductMatch(payload: JsonObject = {}) {
|
||||||
|
const body = resolveKuaishouMatchPayload(payload.rawPayload ?? payload.raw_payload ?? payload)
|
||||||
|
const ext = safeParseJson(body.ext)
|
||||||
|
const order = {
|
||||||
|
provider: '91kaquan',
|
||||||
|
platform: 'kuaishou',
|
||||||
|
shop_id: String(body.sellerId || '').trim(),
|
||||||
|
} as OrderRow
|
||||||
|
const item = {
|
||||||
|
id: 0,
|
||||||
|
order_id: 0,
|
||||||
|
sku_code: String(body.itemId || '').trim(),
|
||||||
|
sku_name: String(ext.skuNick || '').trim(),
|
||||||
|
quantity: Math.max(1, Number(body.num) || 1),
|
||||||
|
spec_json: {},
|
||||||
|
item_snapshot_json: {
|
||||||
|
kuaishouSendCode: {
|
||||||
|
sellerId: String(body.sellerId || '').trim(),
|
||||||
|
itemId: String(body.itemId || '').trim(),
|
||||||
|
itemTitle: String(body.itemTitle || '').trim(),
|
||||||
|
skuId: String(body.skuId || '').trim(),
|
||||||
|
relItemId: normalizeKuaishouMatchId(ext.relItemId),
|
||||||
|
relSkuId: normalizeKuaishouMatchId(ext.relSkuId),
|
||||||
|
skuNick: String(ext.skuNick || '').trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OrderItemRow
|
||||||
|
const [rules, mappings] = await Promise.all([
|
||||||
|
listWorkProductRules({ enabled: true }),
|
||||||
|
listWorkProductRuleMappings({ enabled: true }),
|
||||||
|
])
|
||||||
|
const decision = resolveMatchingProductRuleDecision(order, item, rules, mappings)
|
||||||
|
const context = resolveKuaishouWorkProductMatchContext(item)
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
status: decision.reason,
|
||||||
|
mappingId: decision.mappingId,
|
||||||
|
rule: decision.rule ? mapWorkProductRule(decision.rule) : null,
|
||||||
|
candidates: decision.candidates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminWorkProductMatchLogs(payload: JsonObject = {}) {
|
||||||
|
const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100))
|
||||||
|
return { items: (await listWorkProductMatchLogs(limit)).map(mapWorkProductMatchLog) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWorkProductRuleMapping(mapping: WorkProductRuleMappingRow) {
|
||||||
|
const sellerIds = parseKuaishouMappingSellerIds(mapping.seller_ids_json)
|
||||||
|
return {
|
||||||
|
mappingId: Number(mapping.id),
|
||||||
|
ruleId: Number(mapping.rule_id),
|
||||||
|
ruleKey: String(mapping.rule_key || ''),
|
||||||
|
productName: String(mapping.product_name || ''),
|
||||||
|
sellerIds,
|
||||||
|
sellerId: sellerIds[0] || '',
|
||||||
|
relItemId: String(mapping.rel_item_id || ''),
|
||||||
|
itemTitle: String(mapping.item_title || ''),
|
||||||
|
relSkuId: String(mapping.rel_sku_id || ''),
|
||||||
|
skuNick: String(mapping.sku_nick || ''),
|
||||||
|
mappingType:
|
||||||
|
mapping.mapping_type === 'sku_series'
|
||||||
|
? 'sku_series'
|
||||||
|
: mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override'
|
||||||
|
? 'sku_exact'
|
||||||
|
: 'product_default',
|
||||||
|
enabled: mapping.enabled === true,
|
||||||
|
createdAt: mapping.created_at,
|
||||||
|
updatedAt: mapping.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWorkProductMatchLog(log: WorkProductMatchLogRow) {
|
||||||
|
return {
|
||||||
|
logId: Number(log.id),
|
||||||
|
orderId: log.order_id ? Number(log.order_id) : null,
|
||||||
|
orderItemId: log.order_item_id ? Number(log.order_item_id) : null,
|
||||||
|
source: String(log.source || ''),
|
||||||
|
sellerId: String(log.seller_id || ''),
|
||||||
|
relItemId: String(log.rel_item_id || ''),
|
||||||
|
itemTitle: String(log.item_title || ''),
|
||||||
|
relSkuId: String(log.rel_sku_id || ''),
|
||||||
|
skuNick: String(log.sku_nick || ''),
|
||||||
|
status: String(log.match_status || ''),
|
||||||
|
ruleId: log.rule_id ? Number(log.rule_id) : null,
|
||||||
|
mappingId: log.mapping_id ? Number(log.mapping_id) : null,
|
||||||
|
ruleKey: String(log.rule_key || ''),
|
||||||
|
productName: String(log.product_name || ''),
|
||||||
|
candidates: parseJsonArray(log.candidates_json),
|
||||||
|
rawPayload: safeParseJson(log.raw_payload_json),
|
||||||
|
createdAt: log.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKuaishouMatchPayload(value: unknown): JsonObject {
|
||||||
|
const raw = safeParseJson(value)
|
||||||
|
const body = safeParseJson(raw.body)
|
||||||
|
return Object.keys(body).length > 0 ? body : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKuaishouMatchId(value: unknown) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
return normalized === '0' ? '' : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKuaishouMatchIds(value: unknown) {
|
||||||
|
const values = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: String(value ?? '')
|
||||||
|
.split(/[,,\n]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
return Array.from(new Set(values.map(normalizeKuaishouMatchId).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKuaishouMatchIdList(value: unknown) {
|
||||||
|
const values = Array.isArray(value) ? value : [value]
|
||||||
|
return normalizeKuaishouMatchIds(
|
||||||
|
values.flatMap((item) => String(item ?? '').split(/[,,;;\s\n\r]+/)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKuaishouMappingSellerIds(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return normalizeKuaishouMatchIds(value)
|
||||||
|
try {
|
||||||
|
return normalizeKuaishouMatchIds(JSON.parse(String(value || '[]')))
|
||||||
|
} catch {
|
||||||
|
return normalizeKuaishouMatchIds(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatchTextForAdmin(value: unknown) {
|
||||||
|
return String(value || '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s\u3000]+/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray(value: unknown): unknown[] {
|
||||||
|
if (Array.isArray(value)) return value
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(value || '[]'))
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||||
const defaults = await ensureWorkerPlatformDefaults()
|
const defaults = await ensureWorkerPlatformDefaults()
|
||||||
const match = normalizeWorkProductRuleMatch(payload)
|
const match = normalizeWorkProductRuleMatch(payload)
|
||||||
@@ -1686,12 +1993,40 @@ export async function syncWorkerOrdersForSourceOrder(
|
|||||||
orderItems: OrderItemRow[],
|
orderItems: OrderItemRow[],
|
||||||
options: { source?: string; autoOnly?: boolean; sourceMetadata?: JsonObject } = {},
|
options: { source?: string; autoOnly?: boolean; sourceMetadata?: JsonObject } = {},
|
||||||
) {
|
) {
|
||||||
const rules = await listWorkProductRules({ enabled: true })
|
const [rules, mappings] = await Promise.all([
|
||||||
|
listWorkProductRules({ enabled: true }),
|
||||||
|
listWorkProductRuleMappings({ enabled: true }),
|
||||||
|
])
|
||||||
const created: WorkOrderRow[] = []
|
const created: WorkOrderRow[] = []
|
||||||
const skipped: Array<{ orderItemId: number; reason: string }> = []
|
const skipped: Array<{ orderItemId: number; reason: string }> = []
|
||||||
|
|
||||||
for (const item of orderItems) {
|
for (const item of orderItems) {
|
||||||
const match = resolveMatchingProductRuleDecision(order, item, rules)
|
const match = resolveMatchingProductRuleDecision(order, item, rules, mappings)
|
||||||
|
const matchContext = resolveKuaishouWorkProductMatchContext(item)
|
||||||
|
if (matchContext.sellerId) {
|
||||||
|
const snapshot = safeParseJson(item.item_snapshot_json)
|
||||||
|
await createWorkProductMatchLog({
|
||||||
|
orderId: Number(order.id),
|
||||||
|
orderItemId: Number(item.id),
|
||||||
|
source: options.source || 'source_order',
|
||||||
|
sellerId: matchContext.sellerId,
|
||||||
|
relItemId: matchContext.relItemId,
|
||||||
|
itemTitle: matchContext.itemTitle,
|
||||||
|
relSkuId: matchContext.relSkuId,
|
||||||
|
skuNick: matchContext.skuNick,
|
||||||
|
matchStatus: match.reason,
|
||||||
|
ruleId: match.rule?.id || null,
|
||||||
|
mappingId: match.mappingId,
|
||||||
|
candidatesJson: JSON.stringify(match.candidates),
|
||||||
|
rawPayloadJson: JSON.stringify(
|
||||||
|
options.sourceMetadata?.kuaishouSendCodeRawPayload ||
|
||||||
|
snapshot.kuaishouSendCodeRawPayload ||
|
||||||
|
snapshot.kuaishouSendCode ||
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
now: nowIso(),
|
||||||
|
})
|
||||||
|
}
|
||||||
if (!match.rule) {
|
if (!match.rule) {
|
||||||
skipped.push({
|
skipped.push({
|
||||||
orderItemId: Number(item.id),
|
orderItemId: Number(item.id),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type WorkCategoryRow,
|
type WorkCategoryRow,
|
||||||
type WorkerFinanceRequestRow,
|
type WorkerFinanceRequestRow,
|
||||||
type WorkOrderRow,
|
type WorkOrderRow,
|
||||||
|
type WorkProductRuleMappingRow,
|
||||||
type WorkProductRuleRow,
|
type WorkProductRuleRow,
|
||||||
type WorkerLevelRow,
|
type WorkerLevelRow,
|
||||||
type WorkOrderShareRow,
|
type WorkOrderShareRow,
|
||||||
@@ -609,58 +610,164 @@ export function resolveMatchingProductRule(
|
|||||||
order: OrderRow,
|
order: OrderRow,
|
||||||
item: OrderItemRow,
|
item: OrderItemRow,
|
||||||
rules: WorkProductRuleRow[],
|
rules: WorkProductRuleRow[],
|
||||||
|
mappings: WorkProductRuleMappingRow[] = [],
|
||||||
) {
|
) {
|
||||||
return resolveMatchingProductRuleDecision(order, item, rules).rule
|
return resolveMatchingProductRuleDecision(order, item, rules, mappings).rule
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkProductRuleMatchDecision = {
|
export type WorkProductRuleMatchDecision = {
|
||||||
rule: WorkProductRuleRow | null
|
rule: WorkProductRuleRow | null
|
||||||
|
mappingId: number | null
|
||||||
reason: 'matched' | 'unmatched' | 'ambiguous'
|
reason: 'matched' | 'unmatched' | 'ambiguous'
|
||||||
score: number
|
score: number
|
||||||
candidates: Array<{ ruleKey: string; score: number }>
|
candidates: Array<{ ruleKey: string; score: number; mappingId: number | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KuaishouWorkProductMatchContext = {
|
||||||
|
sellerId: string
|
||||||
|
itemId: string
|
||||||
|
relItemId: string
|
||||||
|
skuId: string
|
||||||
|
relSkuId: string
|
||||||
|
itemTitle: string
|
||||||
|
skuNick: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMatchingProductRuleDecision(
|
export function resolveMatchingProductRuleDecision(
|
||||||
order: OrderRow,
|
order: OrderRow,
|
||||||
item: OrderItemRow,
|
item: OrderItemRow,
|
||||||
rules: WorkProductRuleRow[],
|
rules: WorkProductRuleRow[],
|
||||||
|
mappings: WorkProductRuleMappingRow[] = [],
|
||||||
): WorkProductRuleMatchDecision {
|
): WorkProductRuleMatchDecision {
|
||||||
const candidates = rules
|
const context = resolveKuaishouWorkProductMatchContext(item)
|
||||||
.map((rule) => scoreWorkProductRule(order, item, rule))
|
const rulesById = new Map(rules.map((rule) => [Number(rule.id), rule]))
|
||||||
.filter((candidate): candidate is { rule: WorkProductRuleRow; score: number } =>
|
const candidates = [
|
||||||
Boolean(candidate),
|
...mappings.map((mapping) => scoreWorkProductRuleMapping(context, mapping, rulesById)),
|
||||||
|
...rules.map((rule) => scoreWorkProductRule(order, item, rule)),
|
||||||
|
]
|
||||||
|
.filter(
|
||||||
|
(candidate): candidate is { rule: WorkProductRuleRow; score: number; mappingId?: number } =>
|
||||||
|
Boolean(candidate),
|
||||||
)
|
)
|
||||||
.sort((left, right) => right.score - left.score || Number(right.rule.id) - Number(left.rule.id))
|
.sort((left, right) => right.score - left.score || Number(right.rule.id) - Number(left.rule.id))
|
||||||
|
|
||||||
const top = candidates[0]
|
const top = candidates[0]
|
||||||
if (!top) {
|
if (!top) {
|
||||||
return { rule: null, reason: 'unmatched', score: 0, candidates: [] }
|
return { rule: null, mappingId: null, reason: 'unmatched', score: 0, candidates: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const tied = candidates.filter((candidate) => candidate.score === top.score)
|
const tied = candidates.filter((candidate) => candidate.score === top.score)
|
||||||
if (tied.length > 1) {
|
if (tied.length > 1) {
|
||||||
return {
|
return {
|
||||||
rule: null,
|
rule: null,
|
||||||
|
mappingId: null,
|
||||||
reason: 'ambiguous',
|
reason: 'ambiguous',
|
||||||
score: top.score,
|
score: top.score,
|
||||||
candidates: tied.map((candidate) => ({
|
candidates: tied.map((candidate) => ({
|
||||||
ruleKey: candidate.rule.rule_key,
|
ruleKey: candidate.rule.rule_key,
|
||||||
score: candidate.score,
|
score: candidate.score,
|
||||||
|
mappingId: candidate.mappingId || null,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rule: top.rule,
|
rule: top.rule,
|
||||||
|
mappingId: top.mappingId || null,
|
||||||
reason: 'matched',
|
reason: 'matched',
|
||||||
score: top.score,
|
score: top.score,
|
||||||
candidates: candidates.slice(0, 5).map((candidate) => ({
|
candidates: candidates.slice(0, 5).map((candidate) => ({
|
||||||
ruleKey: candidate.rule.rule_key,
|
ruleKey: candidate.rule.rule_key,
|
||||||
score: candidate.score,
|
score: candidate.score,
|
||||||
|
mappingId: candidate.mappingId || null,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveKuaishouWorkProductMatchContext(
|
||||||
|
item: Pick<OrderItemRow, 'item_snapshot_json'>,
|
||||||
|
): KuaishouWorkProductMatchContext {
|
||||||
|
const snapshot = safeParseJson(item.item_snapshot_json)
|
||||||
|
const source = safeParseJson(snapshot.kuaishouSendCode)
|
||||||
|
return {
|
||||||
|
sellerId: String(source.sellerId || '').trim(),
|
||||||
|
itemId: String(source.itemId || '').trim(),
|
||||||
|
relItemId: normalizeExternalMatchId(source.relItemId),
|
||||||
|
skuId: String(source.skuId || '').trim(),
|
||||||
|
relSkuId: normalizeExternalMatchId(source.relSkuId),
|
||||||
|
itemTitle: String(source.itemTitle || '').trim(),
|
||||||
|
skuNick: String(source.skuNick || source.skuName || snapshot.externalSkuName || '').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreWorkProductRuleMapping(
|
||||||
|
context: KuaishouWorkProductMatchContext,
|
||||||
|
mapping: WorkProductRuleMappingRow,
|
||||||
|
rulesById: Map<number, WorkProductRuleRow>,
|
||||||
|
) {
|
||||||
|
if (!mapping.enabled) return null
|
||||||
|
const rule = rulesById.get(Number(mapping.rule_id))
|
||||||
|
if (!rule || !rule.enabled) return null
|
||||||
|
if (
|
||||||
|
!mappingSellerIds(mapping.seller_ids_json).some((sellerId) =>
|
||||||
|
sameMatchId(context.sellerId, sellerId),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappingType =
|
||||||
|
mapping.mapping_type === 'sku_series'
|
||||||
|
? 'sku_series'
|
||||||
|
: mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override'
|
||||||
|
? 'sku_exact'
|
||||||
|
: 'product_default'
|
||||||
|
const mappedItemIds = normalizeMatchIdList(mapping.rel_item_id)
|
||||||
|
const mappedTitle = normalizeMatchText(mapping.item_title)
|
||||||
|
const hasContextItemId = Boolean(normalizeMatchId(context.relItemId))
|
||||||
|
const itemIdMatched = mappedItemIds.some((mappedItemId) =>
|
||||||
|
sameMatchId(context.relItemId, mappedItemId),
|
||||||
|
)
|
||||||
|
const titleMatched = Boolean(
|
||||||
|
mappedTitle && matchNameCondition(normalizeMatchText(context.itemTitle), mappedTitle, true),
|
||||||
|
)
|
||||||
|
const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle)
|
||||||
|
if (mappingType === 'product_default' || hasProductScope) {
|
||||||
|
// 有商品 ID 时只能按 ID 命中,避免同店同标题的不同商品串单;缺失 ID 才按标题回退。
|
||||||
|
if (
|
||||||
|
(mappedItemIds.length > 0 && hasContextItemId && !itemIdMatched) ||
|
||||||
|
(mappedItemIds.length === 0 && !titleMatched)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = mappingType === 'sku_exact' ? 10_000 : mappingType === 'sku_series' ? 8_000 : 5_000
|
||||||
|
if (itemIdMatched) score += 400
|
||||||
|
if (titleMatched) score += 200
|
||||||
|
if (mappingType === 'sku_exact') {
|
||||||
|
const mappedSkuId = normalizeMatchId(mapping.rel_sku_id)
|
||||||
|
const mappedSkuNick = normalizeMatchText(mapping.sku_nick)
|
||||||
|
if (!mappedSkuId && !mappedSkuNick) return null
|
||||||
|
const skuIdMatched = Boolean(mappedSkuId && sameMatchId(context.relSkuId, mappedSkuId))
|
||||||
|
const skuNickMatched = Boolean(
|
||||||
|
mappedSkuNick && matchNameCondition(normalizeMatchText(context.skuNick), mappedSkuNick, true),
|
||||||
|
)
|
||||||
|
if (!skuIdMatched && !skuNickMatched) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (skuIdMatched) score += 400
|
||||||
|
if (skuNickMatched) score += 200
|
||||||
|
}
|
||||||
|
if (mappingType === 'sku_series') {
|
||||||
|
const mappedSeries = normalizeSkuSeriesName(mapping.sku_nick)
|
||||||
|
const actualSeries = normalizeSkuSeriesName(context.skuNick)
|
||||||
|
if (!mappedSeries || !actualSeries || actualSeries !== mappedSeries) return null
|
||||||
|
score += 200
|
||||||
|
}
|
||||||
|
return { rule, score, mappingId: Number(mapping.id) }
|
||||||
|
}
|
||||||
|
|
||||||
function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkProductRuleRow) {
|
function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkProductRuleRow) {
|
||||||
if (!matchesOptionalText(rule.provider, order.provider)) return null
|
if (!matchesOptionalText(rule.provider, order.provider)) return null
|
||||||
if (!matchesOptionalText(rule.platform, order.platform)) return null
|
if (!matchesOptionalText(rule.platform, order.platform)) return null
|
||||||
@@ -762,6 +869,29 @@ function normalizeMatchId(value: unknown) {
|
|||||||
return normalized === '0' ? '' : normalized.toLowerCase()
|
return normalized === '0' ? '' : normalized.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMatchIdList(value: unknown) {
|
||||||
|
const rawValues = Array.isArray(value) ? value : [value]
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
rawValues
|
||||||
|
.flatMap((entry) => String(entry || '').split(/[,,;;\s\n\r]+/))
|
||||||
|
.map(normalizeMatchId)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalMatchId(value: unknown) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
return normalized === '0' ? '' : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameMatchId(left: unknown, right: unknown) {
|
||||||
|
const normalizedLeft = normalizeMatchId(left)
|
||||||
|
const normalizedRight = normalizeMatchId(right)
|
||||||
|
return Boolean(normalizedLeft) && normalizedLeft === normalizedRight
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMatchText(value: unknown) {
|
function normalizeMatchText(value: unknown) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
@@ -770,6 +900,23 @@ function normalizeMatchText(value: unknown) {
|
|||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mappingSellerIds(value: unknown) {
|
||||||
|
const parsed = Array.isArray(value) ? value : safeParseJson(value)
|
||||||
|
const values = Array.isArray(parsed) ? parsed : []
|
||||||
|
return Array.from(new Set(values.map(normalizeMatchId).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去除 SKU 中的数量规格后得到系列名。
|
||||||
|
* 例如“指挥官密钥10个”和“1个盛夏礼卡”分别归一为“指挥官密钥”“盛夏礼卡”。
|
||||||
|
*/
|
||||||
|
function normalizeSkuSeriesName(value: unknown) {
|
||||||
|
return normalizeMatchText(value).replace(
|
||||||
|
/(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject {
|
export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject {
|
||||||
const match =
|
const match =
|
||||||
payload.match && typeof payload.match === 'object' && !Array.isArray(payload.match)
|
payload.match && typeof payload.match === 'object' && !Array.isArray(payload.match)
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export async function syncWorkOrdersFromKuaishouSendCode(
|
|||||||
const orderItems = await applyKuaishouSendCodeItemData(
|
const orderItems = await applyKuaishouSendCodeItemData(
|
||||||
await listOrderItemsByOrderId(order.id),
|
await listOrderItemsByOrderId(order.id),
|
||||||
sourceData,
|
sourceData,
|
||||||
|
input.rawParams,
|
||||||
input.now,
|
input.now,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ export async function syncWorkOrdersFromKuaishouSendCode(
|
|||||||
autoOnly: true,
|
autoOnly: true,
|
||||||
sourceMetadata: {
|
sourceMetadata: {
|
||||||
kuaishouSendCode: sourceData,
|
kuaishouSendCode: sourceData,
|
||||||
|
kuaishouSendCodeRawPayload: input.rawParams,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
createdWorkOrderCount = syncResult.createdCount
|
createdWorkOrderCount = syncResult.createdCount
|
||||||
@@ -381,6 +383,7 @@ export function resolveKuaishouSendCodeShopName(
|
|||||||
async function applyKuaishouSendCodeItemData(
|
async function applyKuaishouSendCodeItemData(
|
||||||
orderItems: OrderItemRow[],
|
orderItems: OrderItemRow[],
|
||||||
sourceData: KuaishouSendCodeWorkOrderData,
|
sourceData: KuaishouSendCodeWorkOrderData,
|
||||||
|
rawPayload: JsonObject,
|
||||||
now: string,
|
now: string,
|
||||||
): Promise<OrderItemRow[]> {
|
): Promise<OrderItemRow[]> {
|
||||||
if (!sourceData.skuNick && !sourceData.itemTitle) {
|
if (!sourceData.skuNick && !sourceData.itemTitle) {
|
||||||
@@ -408,6 +411,7 @@ async function applyKuaishouSendCodeItemData(
|
|||||||
externalSkuName: sourceData.skuNick || snapshot.externalSkuName || '',
|
externalSkuName: sourceData.skuNick || snapshot.externalSkuName || '',
|
||||||
externalSkuNameNormalized: sourceData.skuNick || snapshot.externalSkuNameNormalized || '',
|
externalSkuNameNormalized: sourceData.skuNick || snapshot.externalSkuNameNormalized || '',
|
||||||
kuaishouSendCode: sourceData,
|
kuaishouSendCode: sourceData,
|
||||||
|
kuaishouSendCodeRawPayload: rawPayload,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const updated = await updateOrderItemSourceSnapshot(item.id, {
|
const updated = await updateOrderItemSourceSnapshot(item.id, {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
WorkOrderRow,
|
WorkOrderRow,
|
||||||
|
WorkProductRuleMappingRow,
|
||||||
WorkProductRuleRow,
|
WorkProductRuleRow,
|
||||||
WorkerUserRow,
|
WorkerUserRow,
|
||||||
} from '../../repositories/worker-platform/index.js'
|
} from '../../repositories/worker-platform/index.js'
|
||||||
@@ -97,6 +98,25 @@ function buildProductRule(overrides: Partial<WorkProductRuleRow> = {}): WorkProd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProductMapping(
|
||||||
|
overrides: Partial<WorkProductRuleMappingRow> = {},
|
||||||
|
): WorkProductRuleMappingRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
rule_id: 1,
|
||||||
|
seller_ids_json: ['3676797936'],
|
||||||
|
rel_item_id: '25677565592936',
|
||||||
|
item_title: '和平精英密钥指挥官特种兵侦察兵密钥',
|
||||||
|
rel_sku_id: '',
|
||||||
|
sku_nick: '',
|
||||||
|
mapping_type: 'product_default',
|
||||||
|
enabled: true,
|
||||||
|
created_at: '2026-08-18T00:00:00.000Z',
|
||||||
|
updated_at: '2026-08-18T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildWorkerUserRow(overrides: Partial<WorkerUserRow> = {}): WorkerUserRow {
|
function buildWorkerUserRow(overrides: Partial<WorkerUserRow> = {}): WorkerUserRow {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -172,6 +192,141 @@ test('快手同优先级精确规则进入冲突状态', () => {
|
|||||||
assert.equal(decision.rule, null)
|
assert.equal(decision.rule, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('快手 SKU 覆盖映射优先于商品默认映射', () => {
|
||||||
|
const defaultRule = buildProductRule({ rule_key: 'commander-default', product_name: '' })
|
||||||
|
const skuRule = buildProductRule({ id: 2, rule_key: 'gift-box-sku', product_name: '' })
|
||||||
|
const decision = resolveMatchingProductRuleDecision(
|
||||||
|
buildOrderRow(),
|
||||||
|
buildOrderItemRow(),
|
||||||
|
[defaultRule, skuRule],
|
||||||
|
[
|
||||||
|
buildProductMapping({ rule_id: defaultRule.id }),
|
||||||
|
buildProductMapping({
|
||||||
|
id: 2,
|
||||||
|
rule_id: skuRule.id,
|
||||||
|
rel_sku_id: '',
|
||||||
|
sku_nick: '1个精英尊尚专属礼盒',
|
||||||
|
mapping_type: 'sku_exact',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(decision.reason, 'matched')
|
||||||
|
assert.equal(decision.rule?.rule_key, 'gift-box-sku')
|
||||||
|
assert.equal(decision.mappingId, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SKU 数量系列可覆盖多店铺的前后数量规格', () => {
|
||||||
|
const order = buildOrderRow()
|
||||||
|
order.shop_id = '3676797937'
|
||||||
|
const item = buildOrderItemRow()
|
||||||
|
item.sku_name = '指挥官密钥10个'
|
||||||
|
item.item_snapshot_json = {
|
||||||
|
kuaishouSendCode: {
|
||||||
|
sellerId: '3676797937',
|
||||||
|
itemId: '26765374805642',
|
||||||
|
itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥',
|
||||||
|
skuId: '189872452606936',
|
||||||
|
relItemId: '26765374805642',
|
||||||
|
relSkuId: '',
|
||||||
|
skuNick: '指挥官密钥10个',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const rule = buildProductRule({ rule_key: 'commander-series', product_name: '' })
|
||||||
|
const decision = resolveMatchingProductRuleDecision(
|
||||||
|
order,
|
||||||
|
item,
|
||||||
|
[rule],
|
||||||
|
[
|
||||||
|
buildProductMapping({
|
||||||
|
rule_id: rule.id,
|
||||||
|
seller_ids_json: ['3676797936', '3676797937'],
|
||||||
|
rel_item_id: '',
|
||||||
|
item_title: '',
|
||||||
|
mapping_type: 'sku_series',
|
||||||
|
sku_nick: '指挥官密钥',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(decision.reason, 'matched')
|
||||||
|
assert.equal(decision.rule?.rule_key, 'commander-series')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('商品默认映射支持用逗号填写多个关联商品 ID', () => {
|
||||||
|
const item = buildOrderItemRow()
|
||||||
|
item.item_snapshot_json = {
|
||||||
|
kuaishouSendCode: {
|
||||||
|
...item.item_snapshot_json.kuaishouSendCode,
|
||||||
|
relItemId: '26765374805642',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const rule = buildProductRule({ product_name: '' })
|
||||||
|
const decision = resolveMatchingProductRuleDecision(
|
||||||
|
buildOrderRow(),
|
||||||
|
item,
|
||||||
|
[rule],
|
||||||
|
[
|
||||||
|
buildProductMapping({
|
||||||
|
rel_item_id: '25677565592936, 26765374805642',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(decision.reason, 'matched')
|
||||||
|
assert.equal(decision.rule?.id, rule.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SKU 数量系列不会按公共词误匹配其他系列', () => {
|
||||||
|
const item = buildOrderItemRow()
|
||||||
|
item.sku_name = '指挥官隐藏款1个'
|
||||||
|
item.item_snapshot_json = {
|
||||||
|
kuaishouSendCode: {
|
||||||
|
sellerId: '3676797936',
|
||||||
|
itemId: '25677565592936',
|
||||||
|
itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥',
|
||||||
|
skuId: '189872452606936',
|
||||||
|
relItemId: '25677565592936',
|
||||||
|
relSkuId: '',
|
||||||
|
skuNick: '指挥官隐藏款1个',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const rule = buildProductRule({ product_name: '' })
|
||||||
|
const decision = resolveMatchingProductRuleDecision(
|
||||||
|
buildOrderRow(),
|
||||||
|
item,
|
||||||
|
[rule],
|
||||||
|
[
|
||||||
|
buildProductMapping({
|
||||||
|
rule_id: rule.id,
|
||||||
|
mapping_type: 'sku_series',
|
||||||
|
sku_nick: '指挥官密钥',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(decision.reason, 'unmatched')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('商品映射不以公共词包含匹配,完整大标题不同则不命中', () => {
|
||||||
|
const rule = buildProductRule({ product_name: '' })
|
||||||
|
const decision = resolveMatchingProductRuleDecision(
|
||||||
|
buildOrderRow(),
|
||||||
|
buildOrderItemRow(),
|
||||||
|
[rule],
|
||||||
|
[
|
||||||
|
buildProductMapping({
|
||||||
|
rule_id: rule.id,
|
||||||
|
rel_item_id: '',
|
||||||
|
item_title: '和平精英指挥官限量礼包',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(decision.reason, 'unmatched')
|
||||||
|
assert.equal(decision.rule, null)
|
||||||
|
})
|
||||||
|
|
||||||
test('assertWorkerLoginAllowed blocks rejected (frozen) workers with worker_rejected', () => {
|
test('assertWorkerLoginAllowed blocks rejected (frozen) workers with worker_rejected', () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'rejected' })),
|
() => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'rejected' })),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import FinancePanel from './panels/FinancePanel'
|
|||||||
import LevelsPanel from './panels/LevelsPanel'
|
import LevelsPanel from './panels/LevelsPanel'
|
||||||
import NotificationsPanel from './panels/NotificationsPanel'
|
import NotificationsPanel from './panels/NotificationsPanel'
|
||||||
import ProductRulesPanel from './panels/ProductRulesPanel'
|
import ProductRulesPanel from './panels/ProductRulesPanel'
|
||||||
|
import ProductMatchPanel from './panels/ProductMatchPanel'
|
||||||
import WorkersPanel from './panels/WorkersPanel'
|
import WorkersPanel from './panels/WorkersPanel'
|
||||||
import WorkOrdersPanel from './panels/WorkOrdersPanel'
|
import WorkOrdersPanel from './panels/WorkOrdersPanel'
|
||||||
|
|
||||||
@@ -48,7 +49,8 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
? [{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> }]
|
? [{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> }]
|
||||||
: [
|
: [
|
||||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||||
{ key: 'rules', label: '物品规则', children: <ProductRulesPanel /> },
|
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
|
||||||
|
{ key: 'product-match', label: '商品匹配', children: <ProductMatchPanel /> },
|
||||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||||
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
|
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
|
||||||
{ key: 'finance', label: '资金', children: <FinancePanel /> },
|
{ key: 'finance', label: '资金', children: <FinancePanel /> },
|
||||||
@@ -69,6 +71,7 @@ function loadActiveTabPreference(): string {
|
|||||||
const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||||
return saved === 'orders' ||
|
return saved === 'orders' ||
|
||||||
saved === 'rules' ||
|
saved === 'rules' ||
|
||||||
|
saved === 'product-match' ||
|
||||||
saved === 'categories' ||
|
saved === 'categories' ||
|
||||||
saved === 'workers' ||
|
saved === 'workers' ||
|
||||||
saved === 'finance' ||
|
saved === 'finance' ||
|
||||||
@@ -86,6 +89,7 @@ function isWorkerPlatformTab(tab: string | null): tab is string {
|
|||||||
return [
|
return [
|
||||||
'orders',
|
'orders',
|
||||||
'rules',
|
'rules',
|
||||||
|
'product-match',
|
||||||
'categories',
|
'categories',
|
||||||
'workers',
|
'workers',
|
||||||
'finance',
|
'finance',
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import { DeleteOutlined, PlayCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
|
import {
|
||||||
|
deleteAdminWorkProductRuleMapping,
|
||||||
|
fetchAdminKuaishouIndustryShops,
|
||||||
|
fetchAdminKuaishouMatchSources,
|
||||||
|
fetchAdminWorkProductMatchLogs,
|
||||||
|
fetchAdminWorkProductRuleMappings,
|
||||||
|
fetchAdminWorkProductRules,
|
||||||
|
saveAdminWorkProductRuleMapping,
|
||||||
|
testAdminKuaishouProductMatch,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type {
|
||||||
|
KuaishouMatchSource,
|
||||||
|
WorkProductMatchLog,
|
||||||
|
WorkProductRuleMapping,
|
||||||
|
} from '@/types/worker-platform'
|
||||||
|
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
|
type MappingFormValues = {
|
||||||
|
mappingId?: number
|
||||||
|
ruleId: number
|
||||||
|
sellerIds: string[]
|
||||||
|
relItemId?: string
|
||||||
|
itemTitle?: string
|
||||||
|
relSkuId?: string
|
||||||
|
skuNick?: string
|
||||||
|
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductMatchPanel() {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [mappingForm] = Form.useForm<MappingFormValues>()
|
||||||
|
const [testPayload, setTestPayload] = useState('')
|
||||||
|
const [testResult, setTestResult] = useState<
|
||||||
|
Awaited<ReturnType<typeof testAdminKuaishouProductMatch>>['data'] | null
|
||||||
|
>(null)
|
||||||
|
const [selectedLog, setSelectedLog] = useState<WorkProductMatchLog | null>(null)
|
||||||
|
|
||||||
|
const rulesQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-product-rules', 'mapping-templates'],
|
||||||
|
queryFn: () => fetchAdminWorkProductRules({ page: 1, pageSize: 100 }),
|
||||||
|
})
|
||||||
|
const mappingsQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-product-mappings'],
|
||||||
|
queryFn: () => fetchAdminWorkProductRuleMappings(),
|
||||||
|
})
|
||||||
|
const shopsQuery = useQuery({
|
||||||
|
queryKey: ['admin-kuaishou-industry-shops', 'product-match'],
|
||||||
|
queryFn: fetchAdminKuaishouIndustryShops,
|
||||||
|
})
|
||||||
|
const sourcesQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-product-match-sources'],
|
||||||
|
queryFn: () => fetchAdminKuaishouMatchSources(200),
|
||||||
|
})
|
||||||
|
const logsQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-product-match-logs'],
|
||||||
|
queryFn: () => fetchAdminWorkProductMatchLogs(100),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = rulesQuery.data?.data.items || []
|
||||||
|
const mappings = mappingsQuery.data?.data.items || []
|
||||||
|
const sources = sourcesQuery.data?.data.items || []
|
||||||
|
const logs = logsQuery.data?.data.items || []
|
||||||
|
const shops = shopsQuery.data?.data.shops || []
|
||||||
|
const shopNameBySellerId = createShopNameBySellerId(shops)
|
||||||
|
const sellerIds = new Set([
|
||||||
|
...shops.map((shop) => shop.sellerId),
|
||||||
|
...sources.map((source) => source.sellerId),
|
||||||
|
])
|
||||||
|
const sellerOptions = Array.from(sellerIds)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.map((sellerId) => ({ value: sellerId, label: formatShopLabel(sellerId, shopNameBySellerId) }))
|
||||||
|
|
||||||
|
function resetMappingForm() {
|
||||||
|
mappingForm.resetFields()
|
||||||
|
mappingForm.setFieldsValue({ mappingType: 'product_default', enabled: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillMappingFromSource(
|
||||||
|
source: KuaishouMatchSource,
|
||||||
|
mappingType: 'product_default' | 'sku_exact' | 'sku_series',
|
||||||
|
) {
|
||||||
|
mappingForm.setFieldsValue({
|
||||||
|
mappingId: undefined,
|
||||||
|
sellerIds: [source.sellerId],
|
||||||
|
relItemId: mappingType === 'product_default' ? source.relItemId : '',
|
||||||
|
itemTitle: mappingType === 'product_default' ? source.itemTitle : '',
|
||||||
|
relSkuId: mappingType === 'sku_exact' ? source.relSkuId : '',
|
||||||
|
skuNick:
|
||||||
|
mappingType === 'product_default'
|
||||||
|
? ''
|
||||||
|
: mappingType === 'sku_series'
|
||||||
|
? normalizeSkuSeriesName(source.skuNick)
|
||||||
|
: source.skuNick,
|
||||||
|
mappingType,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function editMapping(mapping: WorkProductRuleMapping) {
|
||||||
|
mappingForm.setFieldsValue({
|
||||||
|
mappingId: mapping.mappingId,
|
||||||
|
ruleId: mapping.ruleId,
|
||||||
|
sellerIds: mapping.sellerIds,
|
||||||
|
relItemId: mapping.relItemId,
|
||||||
|
itemTitle: mapping.itemTitle,
|
||||||
|
relSkuId: mapping.relSkuId,
|
||||||
|
skuNick: mapping.skuNick,
|
||||||
|
mappingType: mapping.mappingType,
|
||||||
|
enabled: mapping.enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMapping(values: MappingFormValues) {
|
||||||
|
try {
|
||||||
|
await saveAdminWorkProductRuleMapping(values)
|
||||||
|
message.success(values.mappingId ? '商品映射已更新' : '商品映射已创建')
|
||||||
|
resetMappingForm()
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-mappings'] })
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '保存商品映射失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMappingValuesChange(changedValues: Partial<MappingFormValues>) {
|
||||||
|
if (
|
||||||
|
changedValues.sellerIds &&
|
||||||
|
changedValues.sellerIds.length > 1 &&
|
||||||
|
mappingForm.getFieldValue('mappingType') === 'product_default'
|
||||||
|
) {
|
||||||
|
mappingForm.setFieldValue('relItemId', '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMapping(mappingId: number) {
|
||||||
|
try {
|
||||||
|
await deleteAdminWorkProductRuleMapping(mappingId)
|
||||||
|
message.success('商品映射已删除')
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-mappings'] })
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '删除商品映射失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMatchTest() {
|
||||||
|
if (!testPayload.trim()) {
|
||||||
|
message.warning('请选择或粘贴快手原始载荷')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await testAdminKuaishouProductMatch(testPayload)
|
||||||
|
setTestResult(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '匹配测试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappingColumns: TableColumnsType<WorkProductRuleMapping> = [
|
||||||
|
{
|
||||||
|
title: '店铺 / 商品',
|
||||||
|
key: 'product',
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text strong ellipsis={{ tooltip: row.itemTitle }}>
|
||||||
|
{row.itemTitle || row.relItemId}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
店铺{' '}
|
||||||
|
{row.sellerIds
|
||||||
|
.map((sellerId) => formatShopLabel(sellerId, shopNameBySellerId))
|
||||||
|
.join('、')}{' '}
|
||||||
|
{row.relItemId ? `· 商品 ${row.relItemId}` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
{row.mappingType !== 'product_default' ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.mappingType === 'sku_series' ? 'SKU 系列' : 'SKU'} {row.skuNick || row.relSkuId}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '映射类型',
|
||||||
|
dataIndex: 'mappingType',
|
||||||
|
width: 110,
|
||||||
|
render: (value) => (
|
||||||
|
<Tag color={value === 'sku_exact' ? 'purple' : value === 'sku_series' ? 'cyan' : 'blue'}>
|
||||||
|
{value === 'sku_exact'
|
||||||
|
? 'SKU 精确'
|
||||||
|
: value === 'sku_series'
|
||||||
|
? 'SKU 数量系列'
|
||||||
|
: '商品默认'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '接单模板',
|
||||||
|
key: 'template',
|
||||||
|
width: 170,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text>{row.productName || row.ruleKey}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.ruleKey}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 80,
|
||||||
|
render: (enabled) => (
|
||||||
|
<Tag color={enabled ? 'green' : 'default'}>{enabled ? '启用' : '停用'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" onClick={() => editMapping(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="删除此商品映射?" onConfirm={() => deleteMapping(row.mappingId)}>
|
||||||
|
<Button type="text" danger icon={<DeleteOutlined />} aria-label="删除商品映射" />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const sourceColumns: TableColumnsType<KuaishouMatchSource> = [
|
||||||
|
{
|
||||||
|
title: '快手商品 / SKU',
|
||||||
|
key: 'source',
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text strong ellipsis={{ tooltip: row.itemTitle }}>
|
||||||
|
{row.itemTitle || row.relItemId}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
店铺 {formatShopLabel(row.sellerId, shopNameBySellerId)} · {row.skuNick || '商品默认'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '出现',
|
||||||
|
dataIndex: 'seenCount',
|
||||||
|
width: 70,
|
||||||
|
render: (value) => `${value} 次`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近',
|
||||||
|
dataIndex: 'lastSeenAt',
|
||||||
|
width: 160,
|
||||||
|
render: (value) => formatAdminDateTime(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
<Button type="link" onClick={() => fillMappingFromSource(row, 'product_default')}>
|
||||||
|
设为商品默认
|
||||||
|
</Button>
|
||||||
|
<Button type="link" onClick={() => fillMappingFromSource(row, 'sku_exact')}>
|
||||||
|
设为 SKU 精确
|
||||||
|
</Button>
|
||||||
|
<Button type="link" onClick={() => fillMappingFromSource(row, 'sku_series')}>
|
||||||
|
设为 SKU 系列
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={() => {
|
||||||
|
setTestPayload(JSON.stringify(row.rawPayload, null, 2))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const logColumns: TableColumnsType<WorkProductMatchLog> = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 165,
|
||||||
|
render: (value) => formatAdminDateTime(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '店铺 / 商品 / SKU',
|
||||||
|
key: 'target',
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text ellipsis={{ tooltip: row.itemTitle }}>
|
||||||
|
{row.itemTitle || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{formatShopLabel(row.sellerId, shopNameBySellerId)} ·{' '}
|
||||||
|
{row.skuNick || row.relSkuId || '商品默认'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结果',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (value) => <MatchStatusTag status={value} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模板',
|
||||||
|
key: 'rule',
|
||||||
|
width: 170,
|
||||||
|
render: (_, row) => row.productName || row.ruleKey || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '载荷',
|
||||||
|
key: 'payload',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" onClick={() => setSelectedLog(row)}>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card bordered={false} title="商品匹配">
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'mappings',
|
||||||
|
label: `商品映射 (${mappings.length})`,
|
||||||
|
children: (
|
||||||
|
<div className="page-stack">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(280px, 360px) minmax(0, 1fr)',
|
||||||
|
gap: 20,
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={mappingForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ mappingType: 'product_default', enabled: true }}
|
||||||
|
onValuesChange={handleMappingValuesChange}
|
||||||
|
onFinish={saveMapping}
|
||||||
|
>
|
||||||
|
<Form.Item name="mappingId" hidden>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="接单模板"
|
||||||
|
name="ruleId"
|
||||||
|
rules={[{ required: true, message: '请选择接单模板' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="选择可复用模板"
|
||||||
|
options={rules.map((rule) => ({
|
||||||
|
value: rule.ruleId,
|
||||||
|
label: `${rule.productName || rule.ruleKey} (${rule.ruleKey})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="映射类型" name="mappingType">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'product_default', label: '商品默认模板' },
|
||||||
|
{ value: 'sku_series', label: 'SKU 数量系列' },
|
||||||
|
{ value: 'sku_exact', label: 'SKU 精确覆盖' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="覆盖店铺"
|
||||||
|
name="sellerIds"
|
||||||
|
rules={[{ required: true, message: '至少选择一个店铺' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
tokenSeparators={[',', ',']}
|
||||||
|
placeholder="选择快手店铺,可多选"
|
||||||
|
options={sellerOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="关联商品 ID" name="relItemId">
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="可填写多个;用逗号、中文逗号或换行分隔"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="快手大标题" name="itemTitle">
|
||||||
|
<Input placeholder="可选;商品默认必填,同名 SKU 分流时填写" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('mappingType') === 'sku_exact' ? (
|
||||||
|
<>
|
||||||
|
<Form.Item label="关联 SKU ID" name="relSkuId">
|
||||||
|
<Input placeholder="ext.relSkuId,0 自动忽略" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="具体 SKU 名称" name="skuNick">
|
||||||
|
<Input placeholder="ext.skuNick,完整精确匹配" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : getFieldValue('mappingType') === 'sku_series' ? (
|
||||||
|
<Form.Item label="SKU 系列名称" name="skuNick">
|
||||||
|
<Input placeholder="例如:指挥官密钥;自动覆盖 1个、10个等数量规格" />
|
||||||
|
</Form.Item>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
保存映射
|
||||||
|
</Button>
|
||||||
|
<Button onClick={resetMappingForm}>新建</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
<Table<WorkProductRuleMapping>
|
||||||
|
rowKey="mappingId"
|
||||||
|
loading={mappingsQuery.isLoading}
|
||||||
|
columns={mappingColumns}
|
||||||
|
dataSource={mappings}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 10 }}>
|
||||||
|
<Typography.Text strong>已接收的快手商品与 SKU</Typography.Text>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={sourcesQuery.isFetching}
|
||||||
|
onClick={() => sourcesQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Table<KuaishouMatchSource>
|
||||||
|
rowKey={(row) =>
|
||||||
|
`${row.sellerId}-${row.relItemId}-${row.itemTitle}-${row.relSkuId}-${row.skuNick}`
|
||||||
|
}
|
||||||
|
loading={sourcesQuery.isLoading}
|
||||||
|
columns={sourceColumns}
|
||||||
|
dataSource={sources}
|
||||||
|
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'test',
|
||||||
|
label: '匹配测试',
|
||||||
|
children: (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ minWidth: 360 }}
|
||||||
|
placeholder="选择已接收的快手原始载荷"
|
||||||
|
options={sources.map((source, index) => ({
|
||||||
|
value: index,
|
||||||
|
label: `${formatShopLabel(source.sellerId, shopNameBySellerId)} · ${source.itemTitle} · ${source.skuNick || '商品默认'}`,
|
||||||
|
}))}
|
||||||
|
onChange={(index) => {
|
||||||
|
const source = sources[Number(index)]
|
||||||
|
if (source) setTestPayload(JSON.stringify(source.rawPayload, null, 2))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlayCircleOutlined />} onClick={runMatchTest}>
|
||||||
|
测试匹配
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Input.TextArea
|
||||||
|
value={testPayload}
|
||||||
|
onChange={(event) => setTestPayload(event.target.value)}
|
||||||
|
rows={16}
|
||||||
|
placeholder="粘贴快手 send-code 原始载荷"
|
||||||
|
/>
|
||||||
|
{testResult ? (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Descriptions bordered size="small" column={{ xs: 1, md: 2 }}>
|
||||||
|
<Descriptions.Item label="结果">
|
||||||
|
<MatchStatusTag status={testResult.status} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="命中模板">
|
||||||
|
{testResult.rule?.productName || testResult.rule?.ruleKey || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="店铺">
|
||||||
|
{testResult.context.sellerId
|
||||||
|
? formatShopLabel(testResult.context.sellerId, shopNameBySellerId)
|
||||||
|
: '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="具体 SKU">
|
||||||
|
{testResult.context.skuNick || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<JsonPreview
|
||||||
|
value={{ mappingId: testResult.mappingId, candidates: testResult.candidates }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs',
|
||||||
|
label: `匹配日志 (${logs.length})`,
|
||||||
|
children: (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={logsQuery.isFetching}
|
||||||
|
onClick={() => logsQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
仅记录包含快手发码上下文的真实匹配
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Table<WorkProductMatchLog>
|
||||||
|
rowKey="logId"
|
||||||
|
loading={logsQuery.isLoading}
|
||||||
|
columns={logColumns}
|
||||||
|
dataSource={logs}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="匹配原始载荷"
|
||||||
|
open={Boolean(selectedLog)}
|
||||||
|
footer={null}
|
||||||
|
width={760}
|
||||||
|
onCancel={() => setSelectedLog(null)}
|
||||||
|
>
|
||||||
|
<JsonPreview value={selectedLog?.rawPayload} />
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchStatusTag({ status }: { status: string }) {
|
||||||
|
const color = status === 'matched' ? 'green' : status === 'ambiguous' ? 'orange' : 'red'
|
||||||
|
const label = status === 'matched' ? '命中' : status === 'ambiguous' ? '冲突' : '未命中'
|
||||||
|
return <Tag color={color}>{label}</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSkuSeriesName(value: string) {
|
||||||
|
return value
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[\s\u3000]+/g, '')
|
||||||
|
.replace(/(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createShopNameBySellerId(shops: AdminKuaishouIndustryShopOption[]) {
|
||||||
|
const names = new Map<string, string>()
|
||||||
|
for (const shop of shops) {
|
||||||
|
const name = String(shop.shopName || '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
const sellerId = String(shop.sellerId || '').trim()
|
||||||
|
const shopId = String(shop.shopId || '').trim()
|
||||||
|
if (sellerId) names.set(sellerId, name)
|
||||||
|
if (shopId) names.set(shopId, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShopLabel(sellerId: string, shopNameBySellerId: Map<string, string>) {
|
||||||
|
const name = shopNameBySellerId.get(sellerId)
|
||||||
|
return name ? `${name}(${sellerId})` : sellerId
|
||||||
|
}
|
||||||
@@ -159,9 +159,10 @@ export default function ProductRulesPanel() {
|
|||||||
await saveAdminWorkProductRule({
|
await saveAdminWorkProductRule({
|
||||||
...payload,
|
...payload,
|
||||||
ruleId: editingRule?.ruleId,
|
ruleId: editingRule?.ruleId,
|
||||||
|
match: editingRule?.match,
|
||||||
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
|
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
|
||||||
})
|
})
|
||||||
message.success(editingRule ? '物品规则已更新' : '物品规则已创建')
|
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
|
||||||
resetRuleForm()
|
resetRuleForm()
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -218,7 +219,7 @@ export default function ProductRulesPanel() {
|
|||||||
if (editingRule?.ruleId === rule.ruleId) {
|
if (editingRule?.ruleId === rule.ruleId) {
|
||||||
resetRuleForm()
|
resetRuleForm()
|
||||||
}
|
}
|
||||||
message.success('物品规则已删除')
|
message.success('接单模板已删除')
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ['admin-worker-platform-product-rules'],
|
queryKey: ['admin-worker-platform-product-rules'],
|
||||||
})
|
})
|
||||||
@@ -268,20 +269,13 @@ export default function ProductRulesPanel() {
|
|||||||
|
|
||||||
const columns: TableColumnsType<WorkProductRule> = [
|
const columns: TableColumnsType<WorkProductRule> = [
|
||||||
{
|
{
|
||||||
title: '规则 / 商品',
|
title: '模板 / 商品',
|
||||||
key: 'rule',
|
key: 'rule',
|
||||||
width: '29%',
|
width: '29%',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<div className="cell-stack">
|
<div className="cell-stack">
|
||||||
<Typography.Text
|
<Typography.Text strong title={row.productName || row.ruleKey}>
|
||||||
strong
|
{row.productName || row.ruleKey}
|
||||||
title={row.match.skuNick || row.match.itemTitle || row.productName || row.skuCode}
|
|
||||||
>
|
|
||||||
{row.match.skuNick ||
|
|
||||||
row.match.itemTitle ||
|
|
||||||
row.productName ||
|
|
||||||
row.skuCode ||
|
|
||||||
row.ruleKey}
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Space size={4} wrap style={{ marginTop: 2 }}>
|
<Space size={4} wrap style={{ marginTop: 2 }}>
|
||||||
<Tag color="blue" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
|
<Tag color="blue" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
|
||||||
@@ -293,11 +287,6 @@ export default function ProductRulesPanel() {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
{row.match.itemTitle || row.match.skuNick ? (
|
|
||||||
<Typography.Text type="secondary" ellipsis={{ tooltip: true }} style={{ fontSize: 11 }}>
|
|
||||||
{[row.match.itemTitle, row.match.skuNick].filter(Boolean).join(' / ')}
|
|
||||||
</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -442,7 +431,7 @@ export default function ProductRulesPanel() {
|
|||||||
title={
|
title={
|
||||||
<Space size={6}>
|
<Space size={6}>
|
||||||
<FormOutlined style={{ color: '#1677ff' }} />
|
<FormOutlined style={{ color: '#1677ff' }} />
|
||||||
<span>{editingRule ? `编辑规则 · ${editingRule.ruleKey}` : '新建物品规则'}</span>
|
<span>{editingRule ? `编辑模板 · ${editingRule.ruleKey}` : '新建接单模板'}</span>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
@@ -499,71 +488,13 @@ export default function ProductRulesPanel() {
|
|||||||
<Input placeholder="kuaishou" />
|
<Input placeholder="kuaishou" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="店铺" name="shopId">
|
<Form.Item
|
||||||
<Input placeholder="留空通配" />
|
label="模板名称"
|
||||||
</Form.Item>
|
name="productName"
|
||||||
|
className="span-2"
|
||||||
<Form.Item label="SKU" name="skuCode">
|
rules={[{ required: true, message: '请输入模板名称' }]}
|
||||||
<Input placeholder="优先精确匹配" />
|
>
|
||||||
</Form.Item>
|
<Input placeholder="例如:指挥官密钥标准模板" />
|
||||||
|
|
||||||
<Form.Item label="匹配方式" name="matchType">
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: 'contains', label: '包含' },
|
|
||||||
{ value: 'exact', label: '精确' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="商品名称" name="productName" className="span-2">
|
|
||||||
<Input placeholder="旧规则兼容字段;快手订单请配置下方大标题和 SKU" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="快手大标题" name="itemTitle" className="span-2">
|
|
||||||
<Input placeholder="itemTitle,例如:和平精英密钥……" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="大标题匹配" name="itemTitleMatchType">
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: 'exact', label: '精确' },
|
|
||||||
{ value: 'contains', label: '包含' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="具体 SKU 名称" name="skuNick">
|
|
||||||
<Input placeholder="ext.skuNick,例如:1个精英尊尚专属礼盒" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="SKU 名称匹配" name="skuNickMatchType">
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: 'exact', label: '精确' },
|
|
||||||
{ value: 'contains', label: '包含' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="快手卖家 ID" name="sellerId">
|
|
||||||
<Input placeholder="sellerId,留空通配" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="快手商品 ID" name="itemId">
|
|
||||||
<Input placeholder="itemId,精确匹配" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="快手 SKU ID" name="skuId">
|
|
||||||
<Input placeholder="skuId,精确匹配" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="关联商品 ID" name="relItemId">
|
|
||||||
<Input placeholder="ext.relItemId,精确匹配" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="关联 SKU ID" name="relSkuId">
|
|
||||||
<Input placeholder="ext.relSkuId,精确匹配" />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="所属分类" name="categoryId">
|
<Form.Item label="所属分类" name="categoryId">
|
||||||
@@ -714,7 +645,7 @@ export default function ProductRulesPanel() {
|
|||||||
title={
|
title={
|
||||||
<Space align="center" size={10}>
|
<Space align="center" size={10}>
|
||||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||||
物品规则列表
|
接单模板列表
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Tag color="blue" style={{ margin: 0 }}>
|
<Tag color="blue" style={{ margin: 0 }}>
|
||||||
{rulesPagination?.total || 0} 条规则
|
{rulesPagination?.total || 0} 条规则
|
||||||
@@ -725,7 +656,7 @@ export default function ProductRulesPanel() {
|
|||||||
<Space size={8} wrap>
|
<Space size={8} wrap>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="搜索规则/商品/SKU/店铺"
|
placeholder="搜索模板/商品/SKU/店铺"
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
value={keywordInput}
|
value={keywordInput}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import type {
|
|||||||
WorkOrderShare,
|
WorkOrderShare,
|
||||||
WorkOrderStatistics,
|
WorkOrderStatistics,
|
||||||
WorkProductRule,
|
WorkProductRule,
|
||||||
|
WorkProductRuleMapping,
|
||||||
|
KuaishouMatchSource,
|
||||||
|
WorkProductMatchLog,
|
||||||
WorkerFinanceConfig,
|
WorkerFinanceConfig,
|
||||||
WorkerFinanceRequest,
|
WorkerFinanceRequest,
|
||||||
WorkerPlatformNotificationConfig,
|
WorkerPlatformNotificationConfig,
|
||||||
@@ -135,6 +138,63 @@ export function deleteAdminWorkProductRule(ruleId: number) {
|
|||||||
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/product-rules/${ruleId}`)
|
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/product-rules/${ruleId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAdminWorkProductRuleMappings() {
|
||||||
|
return apiGet<{ items: WorkProductRuleMapping[] }>(
|
||||||
|
'/api/v1/admin/worker-platform/product-mappings',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAdminWorkProductRuleMapping(payload: {
|
||||||
|
mappingId?: number
|
||||||
|
ruleId: number
|
||||||
|
sellerIds: string[]
|
||||||
|
relItemId?: string
|
||||||
|
itemTitle?: string
|
||||||
|
relSkuId?: string
|
||||||
|
skuNick?: string
|
||||||
|
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||||
|
enabled?: boolean
|
||||||
|
}) {
|
||||||
|
return apiPost<{ mapping: WorkProductRuleMapping }>(
|
||||||
|
'/api/v1/admin/worker-platform/product-mappings',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAdminWorkProductRuleMapping(mappingId: number) {
|
||||||
|
return apiDelete<{ deleted: boolean }>(
|
||||||
|
`/api/v1/admin/worker-platform/product-mappings/${mappingId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminKuaishouMatchSources(limit = 100) {
|
||||||
|
return apiGet<{ items: KuaishouMatchSource[] }>(
|
||||||
|
'/api/v1/admin/worker-platform/product-match-sources',
|
||||||
|
{
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testAdminKuaishouProductMatch(rawPayload: string) {
|
||||||
|
return apiPost<{
|
||||||
|
context: Record<string, string>
|
||||||
|
status: 'matched' | 'unmatched' | 'ambiguous'
|
||||||
|
mappingId: number | null
|
||||||
|
rule: WorkProductRule | null
|
||||||
|
candidates: Array<{ ruleKey: string; score: number; mappingId: number | null }>
|
||||||
|
}>('/api/v1/admin/worker-platform/product-match-test', { rawPayload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminWorkProductMatchLogs(limit = 100) {
|
||||||
|
return apiGet<{ items: WorkProductMatchLog[] }>(
|
||||||
|
'/api/v1/admin/worker-platform/product-match-logs',
|
||||||
|
{
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function reprocessAdminKuaishouWorkOrderMatches(limit = 100) {
|
export function reprocessAdminKuaishouWorkOrderMatches(limit = 100) {
|
||||||
return apiPost<{ scannedCount: number; createdCount: number; skippedCount: number }>(
|
return apiPost<{ scannedCount: number; createdCount: number; skippedCount: number }>(
|
||||||
'/api/v1/admin/worker-platform/orders/reprocess-matches',
|
'/api/v1/admin/worker-platform/orders/reprocess-matches',
|
||||||
|
|||||||
@@ -226,6 +226,55 @@ export type WorkProductRule = {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkProductRuleMapping = {
|
||||||
|
mappingId: number
|
||||||
|
ruleId: number
|
||||||
|
ruleKey: string
|
||||||
|
productName: string
|
||||||
|
sellerIds: string[]
|
||||||
|
sellerId: string
|
||||||
|
relItemId: string
|
||||||
|
itemTitle: string
|
||||||
|
relSkuId: string
|
||||||
|
skuNick: string
|
||||||
|
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||||
|
enabled: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KuaishouMatchSource = {
|
||||||
|
sellerId: string
|
||||||
|
relItemId: string
|
||||||
|
itemTitle: string
|
||||||
|
relSkuId: string
|
||||||
|
skuNick: string
|
||||||
|
sampleOid: string
|
||||||
|
lastSeenAt: string
|
||||||
|
seenCount: number
|
||||||
|
rawPayload: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkProductMatchLog = {
|
||||||
|
logId: number
|
||||||
|
orderId: number | null
|
||||||
|
orderItemId: number | null
|
||||||
|
source: string
|
||||||
|
sellerId: string
|
||||||
|
relItemId: string
|
||||||
|
itemTitle: string
|
||||||
|
relSkuId: string
|
||||||
|
skuNick: string
|
||||||
|
status: 'matched' | 'unmatched' | 'ambiguous' | string
|
||||||
|
ruleId: number | null
|
||||||
|
mappingId: number | null
|
||||||
|
ruleKey: string
|
||||||
|
productName: string
|
||||||
|
candidates: Array<{ ruleKey?: string; score?: number; mappingId?: number | null }>
|
||||||
|
rawPayload: Record<string, unknown>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export type UploadedFile = {
|
export type UploadedFile = {
|
||||||
objectKey: string
|
objectKey: string
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
Reference in New Issue
Block a user