支持cloudtentacles覆盖规则
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
deleteAdminCloudtentaclesSource,
|
||||
fetchAdminCloudtentaclesVirtualNumberCode,
|
||||
generateAdminCloudtentaclesLoginCode,
|
||||
getAdminCloudtentaclesOverrideRules,
|
||||
getAdminCloudtentaclesAsset,
|
||||
getAdminCloudtentaclesBindUrl,
|
||||
getAdminCloudtentaclesCategories,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
runAdminCloudtentaclesFullFlow,
|
||||
sendAdminCloudtentaclesSmsCode,
|
||||
testAdminCloudtentaclesLogin,
|
||||
updateAdminCloudtentaclesOverrideRules,
|
||||
updateAdminCloudtentaclesSourceConfig,
|
||||
useAdminCloudtentaclesSku,
|
||||
validateAdminCloudtentaclesSession,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
import type {
|
||||
AdminCloudtentaclesCatalogQueryRouteBody,
|
||||
AdminCloudtentaclesFullFlowRouteBody,
|
||||
AdminCloudtentaclesOverrideRuleConfigRouteBody,
|
||||
AdminCloudtentaclesSendSmsCodeRouteBody,
|
||||
AdminCloudtentaclesSkuBuyRouteBody,
|
||||
AdminCloudtentaclesSkuUseRouteBody,
|
||||
@@ -96,6 +99,43 @@ router.post(
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/cloudtentacles/override-rules",
|
||||
createJsonHandler(() => getAdminCloudtentaclesOverrideRules(), {
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取 cloudtentacles 覆盖规则失败",
|
||||
scope: "[admin/platform-config/cloudtentacles/override-rules]",
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/cloudtentacles/override-rules",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
updateAdminCloudtentaclesOverrideRules(
|
||||
req.body as AdminCloudtentaclesOverrideRuleConfigRouteBody
|
||||
),
|
||||
{
|
||||
successMessage: "cloudtentacles 覆盖规则已保存",
|
||||
errorMessage: "保存 cloudtentacles 覆盖规则失败",
|
||||
scope: "[admin/platform-config/cloudtentacles/override-rules]",
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord;
|
||||
return {
|
||||
action: "platform_cloudtentacles_override_rules_updated",
|
||||
targetType: "platform_config",
|
||||
targetId: "cloudtentacles_override_rules",
|
||||
data: {
|
||||
filePath: String(result.filePath || "").trim(),
|
||||
enabled: result.enabled !== false,
|
||||
ruleCount: Array.isArray(result.rules) ? result.rules.length : 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/cloudtentacles/send-sms-code",
|
||||
createJsonHandler(
|
||||
|
||||
@@ -39,6 +39,11 @@ import {
|
||||
verifyCloudtentaclesLoginCode,
|
||||
} from "../../../platforms/cloudtentacles/virtual-number-service.js";
|
||||
import { runCloudtentaclesFullDebugFlow } from "../../../platforms/cloudtentacles/debug-flow-service.js";
|
||||
import {
|
||||
getCloudtentaclesOverrideRuleConfig,
|
||||
getCloudtentaclesOverrideRuleFilePath,
|
||||
saveCloudtentaclesOverrideRuleConfig,
|
||||
} from "../../../order/cloudtentacles-override-rule-service.js";
|
||||
import {
|
||||
hasCloudtentaclesCredentialContextChanged,
|
||||
pickFirstNonEmpty,
|
||||
@@ -62,6 +67,7 @@ import type {
|
||||
AdminCloudtentaclesCatalogQueryInput,
|
||||
AdminCloudtentaclesDeliveryRecordQueryInput,
|
||||
AdminCloudtentaclesFullFlowInput,
|
||||
AdminCloudtentaclesOverrideRuleConfigInput,
|
||||
AdminCloudtentaclesSendSmsCodeInput,
|
||||
AdminCloudtentaclesSkuBuyInput,
|
||||
AdminCloudtentaclesSkuUseInput,
|
||||
@@ -115,6 +121,22 @@ export function listAdminCloudtentaclesRecordSources() {
|
||||
};
|
||||
}
|
||||
|
||||
export function getAdminCloudtentaclesOverrideRules() {
|
||||
return {
|
||||
filePath: getCloudtentaclesOverrideRuleFilePath(),
|
||||
...getCloudtentaclesOverrideRuleConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateAdminCloudtentaclesOverrideRules(
|
||||
payload: AdminCloudtentaclesOverrideRuleConfigInput = {}
|
||||
) {
|
||||
return {
|
||||
filePath: getCloudtentaclesOverrideRuleFilePath(),
|
||||
...saveCloudtentaclesOverrideRuleConfig(payload),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateAdminCloudtentaclesSourceConfig(
|
||||
payload: AdminCloudtentaclesSourceConfigInput = {}
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function normalizeCloudtentaclesMatchName(value: unknown) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[【】\[\]()()]/g, ' ')
|
||||
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
@@ -57,3 +57,91 @@ test('resolveCloudtentaclesSkuByProductName matches SKU name with logged-in sour
|
||||
assert.deepEqual(result?.cloudSourceKeys, ['account-a'])
|
||||
assert.equal(result?.matchMode, 'cloudtentacles_name')
|
||||
})
|
||||
|
||||
test('resolveCloudtentaclesSkuByProductName prefers override rule with multiple delivery items', async () => {
|
||||
const result = await resolveCloudtentaclesSkuByProductName('套装1', {
|
||||
listCloudtentaclesSources: () => ({
|
||||
enabled: true,
|
||||
sources: [
|
||||
{
|
||||
key: 'account-a',
|
||||
label: '账号 A',
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
getCloudtentaclesSessionStateByKey: () => ({
|
||||
token: 'token-a',
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
username: 'user-a',
|
||||
phone: '',
|
||||
loggedInAt: '2026-05-27T12:00:00.000Z',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
}),
|
||||
listCloudtentaclesSku: async () => ({
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
itemCount: 3,
|
||||
rawItems: [],
|
||||
items: [
|
||||
{
|
||||
id: 28,
|
||||
name: '商品A',
|
||||
inventory: 10,
|
||||
price: 360,
|
||||
},
|
||||
{
|
||||
id: 29,
|
||||
name: '商品B',
|
||||
inventory: 20,
|
||||
price: 888,
|
||||
},
|
||||
{
|
||||
id: 30,
|
||||
name: '套装1',
|
||||
inventory: 1,
|
||||
price: 999,
|
||||
},
|
||||
],
|
||||
}),
|
||||
resolveCloudtentaclesOverrideRule: () => ({
|
||||
id: 'combo-1',
|
||||
enabled: true,
|
||||
productName: '套装1',
|
||||
normalizedProductName: normalizeCloudtentaclesMatchName('套装1'),
|
||||
sourceKey: '',
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
],
|
||||
notes: '',
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result?.matchMode, 'cloudtentacles_override')
|
||||
assert.equal(result?.cloudSkuId, 28)
|
||||
assert.equal(result?.cloudSkuName, '商品A')
|
||||
assert.deepEqual(result?.deliveryItems, [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 29,
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
import {
|
||||
listCloudtentaclesSku,
|
||||
} from '../platforms/cloudtentacles/catalog-service.js'
|
||||
import {
|
||||
resolveCloudtentaclesOverrideRule,
|
||||
type CloudtentaclesOverrideRule,
|
||||
} from './cloudtentacles-override-rule-service.js'
|
||||
import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -38,10 +43,17 @@ type CloudtentaclesNameMatchDeps = {
|
||||
listCloudtentaclesSources?: typeof listCloudtentaclesSources
|
||||
getCloudtentaclesSessionStateByKey?: typeof getCloudtentaclesSessionStateByKey
|
||||
listCloudtentaclesSku?: typeof listCloudtentaclesSku
|
||||
resolveCloudtentaclesOverrideRule?: typeof resolveCloudtentaclesOverrideRule
|
||||
}
|
||||
|
||||
export type CloudtentaclesResolvedDeliveryItem = {
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export type CloudtentaclesNameMatchResult = {
|
||||
matchMode: 'cloudtentacles_name'
|
||||
matchMode: 'cloudtentacles_name' | 'cloudtentacles_override'
|
||||
productName: string
|
||||
normalizedProductName: string
|
||||
cloudSkuId: number
|
||||
@@ -50,6 +62,7 @@ export type CloudtentaclesNameMatchResult = {
|
||||
cloudSkuInventory: number
|
||||
cloudSourceKeys: string[]
|
||||
resolvedSourceKey: string
|
||||
deliveryItems: CloudtentaclesResolvedDeliveryItem[]
|
||||
skuSnapshot: JsonObject
|
||||
}
|
||||
|
||||
@@ -65,6 +78,7 @@ export async function resolveCloudtentaclesSkuByProductName(
|
||||
const listSources = deps.listCloudtentaclesSources || listCloudtentaclesSources
|
||||
const getSessionByKey = deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey
|
||||
const listSku = deps.listCloudtentaclesSku || listCloudtentaclesSku
|
||||
const resolveOverrideRule = deps.resolveCloudtentaclesOverrideRule || resolveCloudtentaclesOverrideRule
|
||||
const sourcesConfig = listSources()
|
||||
|
||||
if (sourcesConfig.enabled === false) {
|
||||
@@ -79,6 +93,14 @@ export async function resolveCloudtentaclesSkuByProductName(
|
||||
return null
|
||||
}
|
||||
|
||||
const overrideRule = resolveOverrideRule(productName)
|
||||
const overrideMatch = overrideRule
|
||||
? await resolveOverrideMatch(productName, normalizedProductName, overrideRule, sourceContexts, listSku)
|
||||
: null
|
||||
if (overrideMatch) {
|
||||
return overrideMatch
|
||||
}
|
||||
|
||||
const cloudSourceKeys = sourceContexts.map((context) => context.sourceKey)
|
||||
|
||||
for (const context of sourceContexts) {
|
||||
@@ -109,6 +131,13 @@ export async function resolveCloudtentaclesSkuByProductName(
|
||||
cloudSkuInventory: Number(matchedSku.inventory || 0) || 0,
|
||||
cloudSourceKeys,
|
||||
resolvedSourceKey: context.sourceKey,
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
skuSnapshot: { ...matchedSku },
|
||||
}
|
||||
} catch {
|
||||
@@ -119,16 +148,6 @@ export async function resolveCloudtentaclesSkuByProductName(
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeCloudtentaclesMatchName(value: unknown) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[【】\[\]()()]/g, ' ')
|
||||
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function buildCloudtentaclesSourceContext(
|
||||
source: CloudtentaclesSource,
|
||||
getSessionByKey: typeof getCloudtentaclesSessionStateByKey,
|
||||
@@ -163,3 +182,103 @@ function findCloudtentaclesSkuByName(items: CloudtentaclesSku[], productName: un
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveOverrideMatch(
|
||||
productName: unknown,
|
||||
normalizedProductName: string,
|
||||
rule: CloudtentaclesOverrideRule,
|
||||
sourceContexts: Array<NonNullable<ReturnType<typeof buildCloudtentaclesSourceContext>>>,
|
||||
listSku: typeof listCloudtentaclesSku,
|
||||
): Promise<CloudtentaclesNameMatchResult | null> {
|
||||
const candidates = rule.sourceKey
|
||||
? sourceContexts.filter((context) => context.sourceKey === rule.sourceKey)
|
||||
: sourceContexts
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const context of candidates) {
|
||||
try {
|
||||
const skuList = await listSku(context)
|
||||
const items = Array.isArray(skuList.items) ? skuList.items : []
|
||||
const deliveryItems = resolveOverrideDeliveryItems(rule, items)
|
||||
|
||||
if (deliveryItems.length !== rule.deliveryItems.length || !deliveryItems[0]) {
|
||||
continue
|
||||
}
|
||||
|
||||
const firstDeliveryItem = deliveryItems[0]
|
||||
const firstSku = items.find((item) => Number(item.id || 0) === firstDeliveryItem.cloudSkuId) || {}
|
||||
const cloudSourceKeys = rule.sourceKey
|
||||
? [context.sourceKey]
|
||||
: sourceContexts.map((sourceContext) => sourceContext.sourceKey)
|
||||
|
||||
return {
|
||||
matchMode: 'cloudtentacles_override',
|
||||
productName: String(productName || '').trim(),
|
||||
normalizedProductName,
|
||||
cloudSkuId: firstDeliveryItem.cloudSkuId,
|
||||
cloudSkuName: firstDeliveryItem.cloudSkuName,
|
||||
cloudSkuPrice: Number(firstSku.price || 0) || 0,
|
||||
cloudSkuInventory: Number(firstSku.inventory || 0) || 0,
|
||||
cloudSourceKeys,
|
||||
resolvedSourceKey: context.sourceKey,
|
||||
deliveryItems,
|
||||
skuSnapshot: {
|
||||
overrideRuleId: rule.id,
|
||||
overrideRuleProductName: rule.productName,
|
||||
deliveryItems,
|
||||
firstSku,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
// 当前账号不可用时继续尝试下一个已登录账号。
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveOverrideDeliveryItems(rule: CloudtentaclesOverrideRule, skuItems: CloudtentaclesSku[]) {
|
||||
const resolvedItems: CloudtentaclesResolvedDeliveryItem[] = []
|
||||
|
||||
for (const ruleItem of rule.deliveryItems) {
|
||||
const matchedSku = ruleItem.cloudSkuId > 0
|
||||
? skuItems.find((item) => Number(item.id || 0) === ruleItem.cloudSkuId)
|
||||
: findCloudtentaclesSkuByName(skuItems, ruleItem.cloudSkuName)
|
||||
const cloudSkuId = Number(matchedSku?.id || 0)
|
||||
const cloudSkuName = String(matchedSku?.name || '').trim()
|
||||
|
||||
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) {
|
||||
return []
|
||||
}
|
||||
|
||||
resolvedItems.push({
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
quantity: Math.max(1, Number(ruleItem.quantity || 1)),
|
||||
})
|
||||
}
|
||||
|
||||
return mergeResolvedDeliveryItems(resolvedItems)
|
||||
}
|
||||
|
||||
function mergeResolvedDeliveryItems(items: CloudtentaclesResolvedDeliveryItem[]) {
|
||||
const merged = new Map<number, CloudtentaclesResolvedDeliveryItem>()
|
||||
|
||||
for (const item of items) {
|
||||
const existing = merged.get(item.cloudSkuId)
|
||||
if (existing) {
|
||||
existing.quantity += item.quantity
|
||||
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
|
||||
continue
|
||||
}
|
||||
|
||||
merged.set(item.cloudSkuId, { ...item })
|
||||
}
|
||||
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
export { normalizeCloudtentaclesMatchName }
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
normalizeCloudtentaclesOverrideRuleConfig,
|
||||
} from './cloudtentacles-override-rule-service.js'
|
||||
|
||||
test('normalizeCloudtentaclesOverrideRuleConfig merges duplicated delivery items', () => {
|
||||
const config = normalizeCloudtentaclesOverrideRuleConfig({
|
||||
enabled: true,
|
||||
rules: [
|
||||
{
|
||||
productName: '套装1',
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 2,
|
||||
},
|
||||
{
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.equal(config.enabled, true)
|
||||
assert.equal(config.rules.length, 1)
|
||||
assert.deepEqual(config.rules[0]?.deliveryItems, [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 3,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('normalizeCloudtentaclesOverrideRuleConfig drops invalid rules', () => {
|
||||
const config = normalizeCloudtentaclesOverrideRuleConfig({
|
||||
rules: [
|
||||
{
|
||||
productName: '',
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
productName: '有效商品',
|
||||
deliveryItems: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(config.rules, [])
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||
import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js'
|
||||
import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js'
|
||||
|
||||
const CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH = path.join(
|
||||
PROJECT_ROOT,
|
||||
'data',
|
||||
'cloudtentacles-override-rules.json',
|
||||
)
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export type CloudtentaclesOverrideDeliveryItem = {
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export type CloudtentaclesOverrideRule = {
|
||||
id: string
|
||||
enabled: boolean
|
||||
productName: string
|
||||
normalizedProductName: string
|
||||
sourceKey: string
|
||||
deliveryItems: CloudtentaclesOverrideDeliveryItem[]
|
||||
notes: string
|
||||
}
|
||||
|
||||
export type CloudtentaclesOverrideRuleConfig = {
|
||||
enabled: boolean
|
||||
rules: CloudtentaclesOverrideRule[]
|
||||
}
|
||||
|
||||
export function getCloudtentaclesOverrideRuleFilePath() {
|
||||
return CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH
|
||||
}
|
||||
|
||||
export function getCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig {
|
||||
return readJsonFile(
|
||||
CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH,
|
||||
createDefaultCloudtentaclesOverrideRuleConfig,
|
||||
normalizeCloudtentaclesOverrideRuleConfig,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveCloudtentaclesOverrideRuleConfig(rawValue: unknown): CloudtentaclesOverrideRuleConfig {
|
||||
return writeJsonFile(
|
||||
CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH,
|
||||
rawValue,
|
||||
normalizeCloudtentaclesOverrideRuleConfig,
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveCloudtentaclesOverrideRule(productName: unknown) {
|
||||
const config = getCloudtentaclesOverrideRuleConfig()
|
||||
if (config.enabled === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||
if (!normalizedProductName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return config.rules.find((rule) =>
|
||||
rule.enabled !== false &&
|
||||
rule.normalizedProductName === normalizedProductName &&
|
||||
rule.deliveryItems.length > 0
|
||||
) || null
|
||||
}
|
||||
|
||||
export function normalizeCloudtentaclesOverrideRuleConfig(rawValue: unknown): CloudtentaclesOverrideRuleConfig {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const rules = Array.isArray(source.rules) ? source.rules : []
|
||||
|
||||
return {
|
||||
enabled: source.enabled !== false,
|
||||
rules: rules
|
||||
.map((item: unknown) => normalizeCloudtentaclesOverrideRule(item))
|
||||
.filter((item): item is CloudtentaclesOverrideRule => Boolean(item)),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCloudtentaclesOverrideRule(rawValue: unknown): CloudtentaclesOverrideRule | null {
|
||||
if (!isPlainObject(rawValue)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const productName = String(rawValue.productName || rawValue.name || '').trim()
|
||||
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||
const deliveryItems = normalizeDeliveryItems(rawValue.deliveryItems)
|
||||
|
||||
if (!productName || !normalizedProductName || deliveryItems.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(rawValue.id || normalizedProductName).trim() || normalizedProductName,
|
||||
enabled: rawValue.enabled !== false,
|
||||
productName,
|
||||
normalizedProductName,
|
||||
sourceKey: String(rawValue.sourceKey || '').trim(),
|
||||
deliveryItems,
|
||||
notes: String(rawValue.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDeliveryItems(value: unknown) {
|
||||
const rawItems = Array.isArray(value) ? value : []
|
||||
const items = rawItems
|
||||
.map((item) => normalizeDeliveryItem(item))
|
||||
.filter((item): item is CloudtentaclesOverrideDeliveryItem => Boolean(item))
|
||||
|
||||
return mergeDeliveryItems(items)
|
||||
}
|
||||
|
||||
function normalizeDeliveryItem(value: unknown): CloudtentaclesOverrideDeliveryItem | null {
|
||||
const source = isPlainObject(value) ? value : {}
|
||||
const cloudSkuId = normalizePositiveInteger(source.cloudSkuId || source.skuId, 0)
|
||||
const cloudSkuName = String(source.cloudSkuName || source.skuName || '').trim()
|
||||
const quantity = normalizePositiveInteger(source.quantity, 1)
|
||||
|
||||
if (!cloudSkuId && !cloudSkuName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
quantity,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDeliveryItems(items: CloudtentaclesOverrideDeliveryItem[]) {
|
||||
const merged = new Map<string, CloudtentaclesOverrideDeliveryItem>()
|
||||
|
||||
for (const item of items) {
|
||||
const key = item.cloudSkuId > 0 ? `id:${item.cloudSkuId}` : `name:${normalizeCloudtentaclesMatchName(item.cloudSkuName)}`
|
||||
const existing = merged.get(key)
|
||||
if (existing) {
|
||||
existing.quantity += item.quantity
|
||||
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
|
||||
existing.cloudSkuId = existing.cloudSkuId || item.cloudSkuId
|
||||
continue
|
||||
}
|
||||
|
||||
merged.set(key, { ...item })
|
||||
}
|
||||
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function createDefaultCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
rules: [],
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -194,3 +194,92 @@ test('syncDeliveryTasksForOrderWithDeps creates kuaishou cloud task from cloudte
|
||||
assert.equal(result[0]?.claim_token, 'claim-token')
|
||||
assert.equal(updates[0]?.patch.claim_token, 'claim-token')
|
||||
})
|
||||
|
||||
test('syncDeliveryTasksForOrderWithDeps keeps override delivery items and quantities', async () => {
|
||||
const createdTasks = []
|
||||
|
||||
const dynamicOrderItems = [
|
||||
{
|
||||
id: 22,
|
||||
order_id: 10,
|
||||
sku_code: '套装1',
|
||||
sku_name: '套装1',
|
||||
quantity: 1,
|
||||
spec_json: '{}',
|
||||
item_snapshot_json: JSON.stringify({
|
||||
matchMode: 'cloudtentacles_override',
|
||||
cloudtentacles: {
|
||||
matchMode: 'cloudtentacles_override',
|
||||
normalizedProductName: '套装1',
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
cloudSourceKeys: ['account-a'],
|
||||
resolvedSourceKey: 'account-a',
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 29,
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
await syncDeliveryTasksForOrderWithDeps(paidOrder, dynamicOrderItems, {
|
||||
listTasksByOrderId: async () => [],
|
||||
getFulfillmentProfileByKey: async () => ({
|
||||
id: 2,
|
||||
profile_key: 'kuaishou_ct_assisted',
|
||||
name: '快手 cloud 履约',
|
||||
executor_key: 'kuaishou_ct_assisted',
|
||||
requires_claim: false,
|
||||
auto_dispatch: false,
|
||||
config_json: '{}',
|
||||
}),
|
||||
createTask: async (input) => {
|
||||
createdTasks.push(input)
|
||||
return {
|
||||
id: 32,
|
||||
order_id: input.orderId,
|
||||
order_item_id: input.orderItemId,
|
||||
task_status: input.taskStatus,
|
||||
executor_key: input.executorKey,
|
||||
requires_claim: input.requiresClaim,
|
||||
claim_token: '',
|
||||
primary_claim_token_id: null,
|
||||
last_error: '',
|
||||
context_json: input.contextJson,
|
||||
}
|
||||
},
|
||||
createTaskClaimToken: async () => ({
|
||||
token: 'claim-token',
|
||||
expired_at: '2026-04-15T12:00:00.000Z',
|
||||
}),
|
||||
updateTask: async (taskId, patch) => ({ id: taskId, ...patch }),
|
||||
nowIso: () => '2026-04-14T12:04:00.000Z',
|
||||
randomId: () => 'DT-OVERRIDE',
|
||||
})
|
||||
|
||||
const context = JSON.parse(createdTasks[0].contextJson)
|
||||
assert.equal(context.kuaishouCloudFulfillment.binding.skuId, 28)
|
||||
assert.equal(context.kuaishouCloudFulfillment.binding.resolvedSourceKey, 'account-a')
|
||||
assert.deepEqual(context.kuaishouCloudFulfillment.deliveryItems, [
|
||||
{
|
||||
cloudSkuId: 28,
|
||||
cloudSkuName: '商品A',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
cloudSkuId: 29,
|
||||
cloudSkuName: '商品B',
|
||||
quantity: 20,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -180,6 +180,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
||||
binding: {
|
||||
prepareStatus: 'pending',
|
||||
cloudSourceKeys: normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys),
|
||||
resolvedSourceKey: String(cloudtentaclesConfig.resolvedSourceKey || '').trim(),
|
||||
skuId: primaryDeliveryItem.cloudSkuId,
|
||||
skuName: primaryDeliveryItem.cloudSkuName,
|
||||
vnKey: '1',
|
||||
@@ -384,11 +385,19 @@ async function resolveDynamicCloudtentaclesProfile(
|
||||
): Promise<FulfillmentBindingLike | null> {
|
||||
const snapshot = parseJsonObject(item.item_snapshot_json)
|
||||
const cloudtentacles = parseJsonObject(snapshot.cloudtentacles)
|
||||
const cloudSkuId = Number(cloudtentacles.cloudSkuId || 0) || 0
|
||||
const cloudSkuName = String(cloudtentacles.cloudSkuName || item.sku_name || '').trim()
|
||||
const cloudSourceKeys = normalizeStringArray(cloudtentacles.cloudSourceKeys)
|
||||
const deliveryItems = normalizeCloudDeliveryItems({
|
||||
deliveryItems: cloudtentacles.deliveryItems,
|
||||
skuId: cloudtentacles.cloudSkuId,
|
||||
skuName: cloudtentacles.cloudSkuName || item.sku_name,
|
||||
})
|
||||
const primaryDeliveryItem = deliveryItems[0] || {
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '',
|
||||
quantity: 1,
|
||||
}
|
||||
|
||||
if (!cloudSkuId || !cloudSkuName || cloudSourceKeys.length === 0) {
|
||||
if (!primaryDeliveryItem.cloudSkuId || !primaryDeliveryItem.cloudSkuName || cloudSourceKeys.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -407,18 +416,13 @@ async function resolveDynamicCloudtentaclesProfile(
|
||||
auto_dispatch: false,
|
||||
config_json: {
|
||||
flowType: 'kuaishou_cloud_fulfillment',
|
||||
configId: `cloudtentacles_name:${cloudSkuId}`,
|
||||
configId: `${String(cloudtentacles.matchMode || 'cloudtentacles_name').trim()}:${String(cloudtentacles.normalizedProductName || primaryDeliveryItem.cloudSkuId).trim()}`,
|
||||
cloudtentacles: {
|
||||
cloudSourceKeys,
|
||||
skuId: cloudSkuId,
|
||||
skuName: cloudSkuName,
|
||||
deliveryItems: [
|
||||
{
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
skuId: primaryDeliveryItem.cloudSkuId,
|
||||
skuName: primaryDeliveryItem.cloudSkuName,
|
||||
resolvedSourceKey: String(cloudtentacles.resolvedSourceKey || '').trim(),
|
||||
deliveryItems,
|
||||
vnKey: '1',
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
@@ -429,7 +433,9 @@ async function resolveDynamicCloudtentaclesProfile(
|
||||
shopName: '',
|
||||
autoConsumeAfterDispatch: false,
|
||||
},
|
||||
notes: '91卡券商品名自动匹配 cloudtentacles 商品',
|
||||
notes: cloudtentacles.matchMode === 'cloudtentacles_override'
|
||||
? '91卡券商品名命中 cloudtentacles 覆盖规则'
|
||||
: '91卡券商品名自动匹配 cloudtentacles 商品',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export async function resolveOrderItemForFulfillment({
|
||||
cloudSkuInventory: cloudtentaclesNameMatch.cloudSkuInventory,
|
||||
cloudSourceKeys: cloudtentaclesNameMatch.cloudSourceKeys,
|
||||
resolvedSourceKey: cloudtentaclesNameMatch.resolvedSourceKey,
|
||||
deliveryItems: cloudtentaclesNameMatch.deliveryItems,
|
||||
skuSnapshot: cloudtentaclesNameMatch.skuSnapshot,
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AdminCloudtentaclesCatalogQueryInput,
|
||||
AdminCloudtentaclesDeliveryRecordQueryInput,
|
||||
AdminCloudtentaclesFullFlowInput,
|
||||
AdminCloudtentaclesOverrideRuleConfigInput,
|
||||
AdminCloudtentaclesSendSmsCodeInput,
|
||||
AdminCloudtentaclesSkuBuyInput,
|
||||
AdminCloudtentaclesSkuUseInput,
|
||||
@@ -37,6 +38,7 @@ export type AdminKuaishouEticketDetailQueryRouteBody = AdminKuaishouEticketDetai
|
||||
export type AdminKuaishouEticketConsumeRouteBody = AdminKuaishouEticketConsumeInput
|
||||
export type AdminKuaishouEticketShopInfoRouteBody = AdminKuaishouEticketShopInfoInput
|
||||
export type AdminCloudtentaclesSourceConfigRouteBody = AdminCloudtentaclesSourceConfigInput
|
||||
export type AdminCloudtentaclesOverrideRuleConfigRouteBody = AdminCloudtentaclesOverrideRuleConfigInput
|
||||
export type AdminCloudtentaclesSendSmsCodeRouteBody = AdminCloudtentaclesSendSmsCodeInput
|
||||
export type AdminCloudtentaclesTestLoginRouteBody = AdminCloudtentaclesTestLoginInput
|
||||
export type AdminCloudtentaclesValidateSessionRouteBody = AdminCloudtentaclesValidateSessionInput
|
||||
|
||||
@@ -108,6 +108,26 @@ export type AdminCloudtentaclesSourceConfigInput = {
|
||||
sources?: AdminCloudtentaclesSourceConfigInput[]
|
||||
}
|
||||
|
||||
export type AdminCloudtentaclesOverrideDeliveryItemInput = {
|
||||
cloudSkuId?: number | string
|
||||
cloudSkuName?: string
|
||||
quantity?: number | string
|
||||
}
|
||||
|
||||
export type AdminCloudtentaclesOverrideRuleInput = {
|
||||
id?: string
|
||||
enabled?: boolean
|
||||
productName?: string
|
||||
sourceKey?: string
|
||||
deliveryItems?: AdminCloudtentaclesOverrideDeliveryItemInput[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type AdminCloudtentaclesOverrideRuleConfigInput = {
|
||||
enabled?: boolean
|
||||
rules?: AdminCloudtentaclesOverrideRuleInput[]
|
||||
}
|
||||
|
||||
export type AdminCloudtentaclesSendSmsCodeInput = {
|
||||
sourceKey?: string
|
||||
baseUrl?: string
|
||||
|
||||
Reference in New Issue
Block a user