外部测试内部绑定ok

This commit is contained in:
yml
2026-04-12 13:16:50 +08:00
parent a95247cd01
commit 0029147bc4
31 changed files with 2826 additions and 124 deletions
@@ -77,6 +77,7 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
profileName: String(profile.profile_name || profile.name || ''),
skuCode: item.sku_code,
skuName: item.sku_name,
inventorySkuCode: item.sku_code,
primaryRequirement: primaryRequirement
? {
roleKey: String(primaryRequirement.role_key || primaryRequirement.roleKey || 'primary_code'),
@@ -107,6 +108,7 @@ async function preparePaidTask(task) {
const now = nowIso()
const taskContext = parseTaskContext(task)
const primaryRequirement = taskContext.primaryRequirement || null
const inventorySkuCode = String(taskContext.inventorySkuCode || task.skuCode || '').trim()
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed'].includes(task.task_status)) {
return task
@@ -122,7 +124,7 @@ async function preparePaidTask(task) {
})
}
if (!task.skuCode) {
if (!inventorySkuCode) {
return updateTask(task.id, {
task_status: 'manual_review',
last_error: '未匹配到 SKU,无法为任务分配库存凭据',
@@ -147,7 +149,7 @@ async function preparePaidTask(task) {
}
const reserved = await reserveInventoryForTask({
skuCode: task.skuCode,
skuCode: inventorySkuCode,
taskId: task.id,
credentialType: primaryRequirement.credentialType,
roleKey: primaryRequirement.roleKey || 'primary_code',
@@ -0,0 +1,121 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../config/runtime.js'
const ORDER_FULFILLMENT_BINDINGS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'order-fulfillment-bindings.json')
export function getOrderFulfillmentBindingsFilePath() {
return ORDER_FULFILLMENT_BINDINGS_FILE_PATH
}
export function getOrderFulfillmentBindingConfigs() {
return loadOrderFulfillmentBindingConfigsFromFile()
}
export function saveOrderFulfillmentBindingConfigs(rawValue) {
const normalized = normalizeOrderFulfillmentBindingConfigs(rawValue)
fs.mkdirSync(path.dirname(ORDER_FULFILLMENT_BINDINGS_FILE_PATH), { recursive: true })
fs.writeFileSync(
ORDER_FULFILLMENT_BINDINGS_FILE_PATH,
`${JSON.stringify(normalized, null, 2)}\n`,
'utf8',
)
return normalized
}
function loadOrderFulfillmentBindingConfigsFromFile() {
if (!fs.existsSync(ORDER_FULFILLMENT_BINDINGS_FILE_PATH)) {
return []
}
try {
const rawText = fs.readFileSync(ORDER_FULFILLMENT_BINDINGS_FILE_PATH, 'utf8')
const parsed = JSON.parse(rawText)
return normalizeOrderFulfillmentBindingConfigs(parsed)
} catch {
return []
}
}
function normalizeOrderFulfillmentBindingConfigs(rawValue) {
if (!Array.isArray(rawValue)) {
return []
}
return rawValue
.map((item) => normalizeOrderFulfillmentBinding(item))
.filter(Boolean)
}
function normalizeOrderFulfillmentBinding(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const provider = String(rawValue.provider || 'agiso').trim() || 'agiso'
const platform = String(rawValue.platform || '').trim()
const shopId = String(rawValue.shopId || '').trim()
const skuCode = String(rawValue.skuCode || '').trim()
const skuName = String(rawValue.skuName || '').trim()
const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review'
const priority = normalizePriority(rawValue.priority)
const enabled = rawValue.enabled !== false
const config = normalizeJsonObject(rawValue.config)
const match = normalizeOrderFulfillmentMatch(rawValue.match)
if (!skuCode || !match) {
return null
}
return {
provider,
platform,
shopId,
skuCode,
skuName,
profileKey,
enabled,
priority,
config,
match,
}
}
function normalizeOrderFulfillmentMatch(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const externalSkuCode = String(rawValue.externalSkuCode || '').trim()
const externalItemId = String(rawValue.externalItemId || '').trim()
const externalSkuName = String(rawValue.externalSkuName || '').trim()
if (!externalSkuCode && !externalItemId && !externalSkuName) {
return null
}
return {
externalSkuCode,
externalItemId,
externalSkuName,
config: normalizeJsonObject(rawValue.config),
}
}
function normalizePriority(value) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return 100
}
return Math.max(1, Math.round(parsed))
}
function normalizeJsonObject(value) {
return isPlainObject(value) ? value : {}
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -4,6 +4,7 @@ import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
import { buildClaimUrl } from '../claim/claim-service.js'
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js'
import { resolveOrderItemForFulfillment } from './product-match-service.js'
import { nowIso } from '../../utils/time.js'
import { logWebhook } from '../../utils/logger.js'
@@ -53,9 +54,18 @@ export async function upsertOrderFromWebhook(event) {
updatedAt: now,
})
const resolvedItems = await Promise.all(
event.items.map((item) => resolveOrderItemForFulfillment({
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
item,
})),
)
const orderItems = await replaceOrderItems(
order.id,
event.items.map((item) => ({
resolvedItems.map((item) => ({
skuCode: item.skuCode,
skuName: item.skuName,
quantity: item.quantity,
@@ -78,6 +88,7 @@ export async function upsertOrderFromWebhook(event) {
platformOrderId: order.platform_order_id,
orderItemCount: orderItems.length,
taskCount: tasks.length,
resolvedSkuCodes: resolvedItems.map((item) => item.skuCode),
})
for (const task of tasks) {
@@ -0,0 +1,113 @@
import { resolveProductMatchRule } from '../../repositories/product-match-rule-repo.js'
export async function resolveOrderItemForFulfillment({
provider = '',
platform = '',
shopId = '',
item = {},
}) {
const externalItemId = pickFirstNonEmpty([
item.externalItemId,
item.itemId,
])
const externalSkuCode = pickFirstNonEmpty([
item.externalSkuCode,
item.skuCode,
externalItemId,
])
const externalSkuName = pickFirstNonEmpty([
item.externalSkuName,
item.skuName,
externalSkuCode,
])
const externalSkuNameNormalized = normalizeProductName(externalSkuName)
const matchedRule = await resolveProductMatchRule({
provider,
platform,
shopId,
externalItemId,
externalSkuCode,
externalSkuNameNormalized,
})
const resolvedSkuCode = pickFirstNonEmpty([
matchedRule?.resolved_sku_code,
externalSkuCode,
externalItemId,
])
const resolvedSkuName = pickFirstNonEmpty([
readConfigValue(matchedRule?.config_json, 'resolvedSkuName'),
readConfigValue(matchedRule?.config_json, 'internalProductName'),
item.skuName,
externalSkuName,
resolvedSkuCode,
])
const snapshot = {
...(isPlainObject(item.snapshot) ? item.snapshot : {}),
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
resolvedSkuCode,
matchedProductRuleId: matchedRule ? Number(matchedRule.id) : null,
matchedProductRuleBy: String(matchedRule?.matched_by || '').trim(),
}
return {
...item,
itemId: externalItemId,
externalItemId,
externalSkuCode,
externalSkuName,
skuCode: resolvedSkuCode,
skuName: resolvedSkuName,
snapshot,
}
}
export function normalizeProductName(value) {
return String(value || '')
.toLowerCase()
.replace(/[【】\[\]()()]/g, ' ')
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function pickFirstNonEmpty(values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function readConfigValue(rawValue, key) {
if (!rawValue) {
return ''
}
const parsed = typeof rawValue === 'string' ? safeParseJson(rawValue) : rawValue
if (!isPlainObject(parsed)) {
return ''
}
return String(parsed[key] || '').trim()
}
function safeParseJson(rawValue) {
try {
return JSON.parse(String(rawValue || '{}'))
} catch {
return null
}
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -533,7 +533,17 @@ function normalizeOrderItems(payload) {
return items.map((item) => {
const source = isPlainObject(item) ? item : {}
const skuDescriptor = parseExternalSkuDescriptor(
pickFirstNonEmpty([
source.sku,
source.Sku,
payload.sku,
payload.Sku,
payload._agisoTradeDetail?.sku,
]),
)
const rawSkuCandidates = [
skuDescriptor.externalSkuCode,
source.OuterSkuId,
source.outerSkuId,
source.outer_sku_id,
@@ -553,18 +563,31 @@ function normalizeOrderItems(payload) {
payload.itemId,
]
const skuCode = resolveSkuCode(rawSkuCandidates)
const externalItemId = pickFirstNonEmpty([
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
payload.item_id,
payload.itemId,
])
const externalSkuName = pickFirstNonEmpty([
skuDescriptor.externalSkuName,
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
])
return {
skuCode,
skuName: pickFirstNonEmpty([
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
]),
skuName: externalSkuName,
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
quantity: Math.max(
1,
normalizeInteger(
@@ -580,6 +603,11 @@ function normalizeOrderItems(payload) {
) || 1,
),
spec: source,
snapshot: {
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
},
}
})
}
@@ -756,6 +784,56 @@ function resolveSkuCode(rawKey) {
return ''
}
function parseExternalSkuDescriptor(rawValue) {
const raw = String(rawValue || '').trim()
if (!raw) {
return {
raw,
externalSkuCode: '',
externalSkuName: '',
}
}
const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean)
let externalSkuCode = ''
let externalSkuName = ''
for (const part of parts) {
if (!externalSkuCode && !part.includes(':') && !part.includes('')) {
externalSkuCode = part
continue
}
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
if (separatorIndex >= 0) {
const label = part.slice(0, separatorIndex).trim()
const value = part.slice(separatorIndex + 1).trim()
if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) {
externalSkuName = value
}
}
}
if (!externalSkuCode && parts[0]) {
externalSkuCode = parts[0]
}
if (!externalSkuName) {
externalSkuName = parts
.map((part) => {
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : ''
})
.find(Boolean) || ''
}
return {
raw,
externalSkuCode,
externalSkuName,
}
}
function normalizeRecord(value) {
return isPlainObject(value) ? value : {}
}