From f2c95bc3c8b8d3f035224d9f8039cf53190d3919 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 18 Aug 2026 12:05:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8E=A5=E5=8D=95=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../034_worker_product_match_catalog.sql | 58 ++ .../kuaishou-industry-voucher-repo.ts | 3 +- .../src/repositories/worker-platform/index.ts | 1 + .../worker-platform/product-match-repo.ts | 181 +++++ .../src/repositories/worker-platform/types.ts | 36 + .../src/routes/admin/worker-platform.ts | 75 +++ .../services/worker-platform/admin-service.ts | 339 +++++++++- .../src/services/worker-platform/mappers.ts | 161 ++++- .../sync-work-orders-from-send-code.ts | 4 + .../worker-platform-service.test.ts | 155 +++++ .../pages/admin/AdminWorkerPlatformPage.tsx | 6 +- .../pages/admin/panels/ProductMatchPanel.tsx | 623 ++++++++++++++++++ .../pages/admin/panels/ProductRulesPanel.tsx | 101 +-- .../src/services/admin/worker-platform.ts | 60 ++ apps/frontend/src/types/worker-platform.ts | 49 ++ 15 files changed, 1756 insertions(+), 96 deletions(-) create mode 100644 apps/backend/src/db/migrations/034_worker_product_match_catalog.sql create mode 100644 apps/backend/src/repositories/worker-platform/product-match-repo.ts create mode 100644 apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx diff --git a/apps/backend/src/db/migrations/034_worker_product_match_catalog.sql b/apps/backend/src/db/migrations/034_worker_product_match_catalog.sql new file mode 100644 index 00000000..3757108a --- /dev/null +++ b/apps/backend/src/db/migrations/034_worker_product_match_catalog.sql @@ -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 '快手接单商品匹配日志与调试载荷快照'; diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts index 19128d83..36643f97 100644 --- a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -204,6 +204,7 @@ export async function listKuaishouIndustryVouchersByOid( export type KuaishouIndustryVoucherWorkOrderSyncSource = { oid: string raw_payload_json: string | Record + updated_at: string } export async function listKuaishouIndustryVoucherWorkOrderSyncSources( @@ -211,7 +212,7 @@ export async function listKuaishouIndustryVoucherWorkOrderSyncSources( ): Promise { const result = await query( ` - SELECT oid, raw_payload_json + SELECT oid, raw_payload_json, updated_at FROM ( SELECT DISTINCT ON (oid) oid, raw_payload_json, updated_at, id FROM kuaishou_industry_vouchers diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts index e730d088..bbadf8dd 100644 --- a/apps/backend/src/repositories/worker-platform/index.ts +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -2,4 +2,5 @@ export * from './types.js' export * from './shared.js' export * from './worker-repo.js' export * from './work-order-repo.js' +export * from './product-match-repo.js' export * from './sms-code-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/product-match-repo.ts b/apps/backend/src/repositories/worker-platform/product-match-repo.ts new file mode 100644 index 00000000..0b74aa58 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/product-match-repo.ts @@ -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 { + 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( + `${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 { + if (input.mappingId) { + const result = await query( + ` + 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( + ` + 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 { + const result = await query( + ` + 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 { + const result = await query( + ` + 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 +} diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 53549a82..b0cfdae5 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -160,6 +160,42 @@ export type WorkProductRuleRow = { 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 | unknown[] + raw_payload_json: string | Record + created_at: string + rule_key?: string + product_name?: string +} + export type WorkOrderShareRow = { id: number work_order_id: number diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index db6cbb40..3c7879d0 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -21,6 +21,9 @@ import { listAdminWorkerFinanceRequests, listAdminWorkerWithdrawalAccounts, listAdminWorkCategories, + listAdminKuaishouMatchSources, + listAdminWorkProductMatchLogs, + listAdminWorkProductRuleMappings, listAdminWorkProductRules, listAdminWorkerLevels, listAdminWorkerUsers, @@ -36,12 +39,15 @@ import { saveAdminWorkerPlatformNotificationConfig, saveAdminWorkerWithdrawalAccount, saveAdminWorkCategory, + saveAdminWorkProductRuleMapping, saveAdminWorkProductRule, + testAdminKuaishouProductMatch, saveAdminWorkerLevel, submitAdminWorkOrderMaterial, unpublishAdminWorkOrder, updateAdminWorkOrder, updateAdminWorkOrderSharing, + deleteAdminWorkProductRuleMapping, } from '../../services/worker-platform/index.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) : {}, + }), + }), +) + +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( '/worker-platform/workers', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 2c21eb8e..a3e30c96 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -1,4 +1,5 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { listKuaishouIndustryVoucherWorkOrderSyncSources } from '../../repositories/kuaishou-industry-voucher-repo.js' import { acceptWorkOrderAndSettle, addWorkerWalletCredit, @@ -12,10 +13,12 @@ import { countWorkProductRulesByCategory, countWorkOrderPendingSharingSubmissions, countTimeoutEventsByWorkerIds, + createWorkProductMatchLog, createWorkOrder, createWorkOrderEvent, deductPendingDepositUnfreeze, deleteWorkCategory, + deleteWorkProductRuleMapping, deleteWorkProductRule, deleteWorkOrder, deleteWorkerLevel, @@ -32,6 +35,8 @@ import { listWorkOrders, listWorkOrderEventsByOrderId, listWorkProductRules, + listWorkProductRuleMappings, + listWorkProductMatchLogs, listWorkProductRulesPage, listWorkerLevels, listWorkerUsers, @@ -48,8 +53,11 @@ import { upsertWorkerWithdrawalAccount, upsertWorkCategory, upsertWorkProductRule, + upsertWorkProductRuleMapping, upsertWorkerLevel, type WorkOrderRow, + type WorkProductMatchLogRow, + type WorkProductRuleMappingRow, type WorkOrderShareRow, type WorkerWithdrawalAccountRow, } from '../../repositories/worker-platform/index.js' @@ -118,6 +126,7 @@ import { resolveFreezeDepositAmount, mapWorkOrderEvents, resolveMatchingProductRuleDecision, + resolveKuaishouWorkProductMatchContext, resolveRequirementFields, resolveSkuNameQuantity, resolveWorkerPermissions, @@ -269,6 +278,304 @@ export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObje 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() + 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 = {}) { const defaults = await ensureWorkerPlatformDefaults() const match = normalizeWorkProductRuleMatch(payload) @@ -1686,12 +1993,40 @@ export async function syncWorkerOrdersForSourceOrder( orderItems: OrderItemRow[], 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 skipped: Array<{ orderItemId: number; reason: string }> = [] 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) { skipped.push({ orderItemId: Number(item.id), diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 6dcfd803..79c42534 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -7,6 +7,7 @@ import { type WorkCategoryRow, type WorkerFinanceRequestRow, type WorkOrderRow, + type WorkProductRuleMappingRow, type WorkProductRuleRow, type WorkerLevelRow, type WorkOrderShareRow, @@ -609,58 +610,164 @@ export function resolveMatchingProductRule( order: OrderRow, item: OrderItemRow, rules: WorkProductRuleRow[], + mappings: WorkProductRuleMappingRow[] = [], ) { - return resolveMatchingProductRuleDecision(order, item, rules).rule + return resolveMatchingProductRuleDecision(order, item, rules, mappings).rule } export type WorkProductRuleMatchDecision = { rule: WorkProductRuleRow | null + mappingId: number | null reason: 'matched' | 'unmatched' | 'ambiguous' 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( order: OrderRow, item: OrderItemRow, rules: WorkProductRuleRow[], + mappings: WorkProductRuleMappingRow[] = [], ): WorkProductRuleMatchDecision { - const candidates = rules - .map((rule) => scoreWorkProductRule(order, item, rule)) - .filter((candidate): candidate is { rule: WorkProductRuleRow; score: number } => - Boolean(candidate), + const context = resolveKuaishouWorkProductMatchContext(item) + const rulesById = new Map(rules.map((rule) => [Number(rule.id), rule])) + const candidates = [ + ...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)) const top = candidates[0] 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) if (tied.length > 1) { return { rule: null, + mappingId: null, reason: 'ambiguous', score: top.score, candidates: tied.map((candidate) => ({ ruleKey: candidate.rule.rule_key, score: candidate.score, + mappingId: candidate.mappingId || null, })), } } return { rule: top.rule, + mappingId: top.mappingId || null, reason: 'matched', score: top.score, candidates: candidates.slice(0, 5).map((candidate) => ({ ruleKey: candidate.rule.rule_key, score: candidate.score, + mappingId: candidate.mappingId || null, })), } } +export function resolveKuaishouWorkProductMatchContext( + item: Pick, +): 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, +) { + 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) { if (!matchesOptionalText(rule.provider, order.provider)) return null if (!matchesOptionalText(rule.platform, order.platform)) return null @@ -762,6 +869,29 @@ function normalizeMatchId(value: unknown) { 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) { return String(value || '') .normalize('NFKC') @@ -770,6 +900,23 @@ function normalizeMatchText(value: unknown) { .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 { const match = payload.match && typeof payload.match === 'object' && !Array.isArray(payload.match) diff --git a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts index ae939a21..f6bcd733 100644 --- a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts +++ b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts @@ -132,6 +132,7 @@ export async function syncWorkOrdersFromKuaishouSendCode( const orderItems = await applyKuaishouSendCodeItemData( await listOrderItemsByOrderId(order.id), sourceData, + input.rawParams, input.now, ) @@ -140,6 +141,7 @@ export async function syncWorkOrdersFromKuaishouSendCode( autoOnly: true, sourceMetadata: { kuaishouSendCode: sourceData, + kuaishouSendCodeRawPayload: input.rawParams, }, }) createdWorkOrderCount = syncResult.createdCount @@ -381,6 +383,7 @@ export function resolveKuaishouSendCodeShopName( async function applyKuaishouSendCodeItemData( orderItems: OrderItemRow[], sourceData: KuaishouSendCodeWorkOrderData, + rawPayload: JsonObject, now: string, ): Promise { if (!sourceData.skuNick && !sourceData.itemTitle) { @@ -408,6 +411,7 @@ async function applyKuaishouSendCodeItemData( externalSkuName: sourceData.skuNick || snapshot.externalSkuName || '', externalSkuNameNormalized: sourceData.skuNick || snapshot.externalSkuNameNormalized || '', kuaishouSendCode: sourceData, + kuaishouSendCodeRawPayload: rawPayload, }, } const updated = await updateOrderItemSourceSnapshot(item.id, { diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index d8e84d86..7d22561d 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -3,6 +3,7 @@ import test from 'node:test' import type { WorkOrderRow, + WorkProductRuleMappingRow, WorkProductRuleRow, WorkerUserRow, } from '../../repositories/worker-platform/index.js' @@ -97,6 +98,25 @@ function buildProductRule(overrides: Partial = {}): WorkProd } } +function buildProductMapping( + overrides: Partial = {}, +): 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 { return { id: 1, @@ -172,6 +192,141 @@ test('快手同优先级精确规则进入冲突状态', () => { 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', () => { assert.throws( () => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'rejected' })), diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 4be3a2b1..c40b3453 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -11,6 +11,7 @@ import FinancePanel from './panels/FinancePanel' import LevelsPanel from './panels/LevelsPanel' import NotificationsPanel from './panels/NotificationsPanel' import ProductRulesPanel from './panels/ProductRulesPanel' +import ProductMatchPanel from './panels/ProductMatchPanel' import WorkersPanel from './panels/WorkersPanel' import WorkOrdersPanel from './panels/WorkOrdersPanel' @@ -48,7 +49,8 @@ export default function AdminWorkerPlatformPage() { ? [{ key: 'orders', label: '接单工单', children: }] : [ { key: 'orders', label: '接单工单', children: }, - { key: 'rules', label: '物品规则', children: }, + { key: 'rules', label: '接单模板', children: }, + { key: 'product-match', label: '商品匹配', children: }, { key: 'categories', label: '分类', children: }, { key: 'workers', label: '打手', children: }, { key: 'finance', label: '资金', children: }, @@ -69,6 +71,7 @@ function loadActiveTabPreference(): string { const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY) return saved === 'orders' || saved === 'rules' || + saved === 'product-match' || saved === 'categories' || saved === 'workers' || saved === 'finance' || @@ -86,6 +89,7 @@ function isWorkerPlatformTab(tab: string | null): tab is string { return [ 'orders', 'rules', + 'product-match', 'categories', 'workers', 'finance', diff --git a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx new file mode 100644 index 00000000..37a7c46e --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx @@ -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() + const [testPayload, setTestPayload] = useState('') + const [testResult, setTestResult] = useState< + Awaited>['data'] | null + >(null) + const [selectedLog, setSelectedLog] = useState(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) { + 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 = [ + { + title: '店铺 / 商品', + key: 'product', + render: (_, row) => ( +
+ + {row.itemTitle || row.relItemId} + + + 店铺{' '} + {row.sellerIds + .map((sellerId) => formatShopLabel(sellerId, shopNameBySellerId)) + .join('、')}{' '} + {row.relItemId ? `· 商品 ${row.relItemId}` : ''} + + {row.mappingType !== 'product_default' ? ( + + {row.mappingType === 'sku_series' ? 'SKU 系列' : 'SKU'} {row.skuNick || row.relSkuId} + + ) : null} +
+ ), + }, + { + title: '映射类型', + dataIndex: 'mappingType', + width: 110, + render: (value) => ( + + {value === 'sku_exact' + ? 'SKU 精确' + : value === 'sku_series' + ? 'SKU 数量系列' + : '商品默认'} + + ), + }, + { + title: '接单模板', + key: 'template', + width: 170, + render: (_, row) => ( +
+ {row.productName || row.ruleKey} + + {row.ruleKey} + +
+ ), + }, + { + title: '状态', + dataIndex: 'enabled', + width: 80, + render: (enabled) => ( + {enabled ? '启用' : '停用'} + ), + }, + { + title: '操作', + key: 'actions', + width: 100, + render: (_, row) => ( + + + deleteMapping(row.mappingId)}> + + + + + + ), + }, + ] + + const logColumns: TableColumnsType = [ + { + title: '时间', + dataIndex: 'createdAt', + width: 165, + render: (value) => formatAdminDateTime(value), + }, + { + title: '店铺 / 商品 / SKU', + key: 'target', + render: (_, row) => ( +
+ + {row.itemTitle || '-'} + + + {formatShopLabel(row.sellerId, shopNameBySellerId)} ·{' '} + {row.skuNick || row.relSkuId || '商品默认'} + +
+ ), + }, + { + title: '结果', + dataIndex: 'status', + width: 100, + render: (value) => , + }, + { + title: '模板', + key: 'rule', + width: 170, + render: (_, row) => row.productName || row.ruleKey || '-', + }, + { + title: '载荷', + key: 'payload', + width: 80, + render: (_, row) => ( + + ), + }, + ] + + return ( + + +
+
+ + + + + + + + + {({ getFieldValue }) => + getFieldValue('mappingType') === 'sku_exact' ? ( + <> + + + + + + + + ) : getFieldValue('mappingType') === 'sku_series' ? ( + + + + ) : null + } + + + + + + + + +
+ + rowKey="mappingId" + loading={mappingsQuery.isLoading} + columns={mappingColumns} + dataSource={mappings} + pagination={false} + size="small" + /> +
+
+ + 已接收的快手商品与 SKU + + + + 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" + /> +
+ + ), + }, + { + key: 'test', + label: '匹配测试', + children: ( +
+ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -714,7 +645,7 @@ export default function ProductRulesPanel() { title={ - 物品规则列表 + 接单模板列表 {rulesPagination?.total || 0} 条规则 @@ -725,7 +656,7 @@ export default function ProductRulesPanel() { { diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 13ceaf8e..b508153c 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -8,6 +8,9 @@ import type { WorkOrderShare, WorkOrderStatistics, WorkProductRule, + WorkProductRuleMapping, + KuaishouMatchSource, + WorkProductMatchLog, WorkerFinanceConfig, WorkerFinanceRequest, WorkerPlatformNotificationConfig, @@ -135,6 +138,63 @@ export function deleteAdminWorkProductRule(ruleId: number) { 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 + 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) { return apiPost<{ scannedCount: number; createdCount: number; skippedCount: number }>( '/api/v1/admin/worker-platform/orders/reprocess-matches', diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 70a824ab..51179e1d 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -226,6 +226,55 @@ export type WorkProductRule = { 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 +} + +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 + createdAt: string +} + export type UploadedFile = { objectKey: string url: string