外部测试内部绑定ok
This commit is contained in:
@@ -7,6 +7,12 @@ import {
|
||||
getAgisoShopsFilePath,
|
||||
saveAgisoShopConfigMap,
|
||||
} from '../platforms/agiso/shop-config-service.js'
|
||||
import {
|
||||
getOrderFulfillmentBindingConfigs,
|
||||
getOrderFulfillmentBindingsFilePath,
|
||||
saveOrderFulfillmentBindingConfigs,
|
||||
} from '../order/fulfillment-binding-config-service.js'
|
||||
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||
import {
|
||||
createInventoryItems,
|
||||
@@ -31,6 +37,8 @@ import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/m
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { reserveInventoryForTask } from '../order/inventory-service.js'
|
||||
import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js'
|
||||
import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { normalizeProductName } from '../order/product-match-service.js'
|
||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||
|
||||
export async function getAdminDashboardSummary() {
|
||||
@@ -294,6 +302,7 @@ export async function getAdminInventoryItems(query = {}) {
|
||||
page,
|
||||
pageSize,
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
credentialType: String(query.credentialType || '').trim(),
|
||||
status: String(query.status || '').trim(),
|
||||
batchNo: String(query.batchNo || '').trim(),
|
||||
})
|
||||
@@ -581,6 +590,145 @@ export function updateAdminAgisoShopConfigs(payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminFulfillmentBindingConfigs() {
|
||||
const bindings = getOrderFulfillmentBindingConfigs()
|
||||
const rowsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
o.provider,
|
||||
o.platform,
|
||||
o.shop_id,
|
||||
MAX(CASE WHEN trim(o.shop_name) != '' THEN o.shop_name ELSE '' END) AS shop_name,
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') AS external_item_id,
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') AS external_sku_code,
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') AS external_sku_name,
|
||||
MAX(oi.created_at) AS latest_seen_at,
|
||||
COUNT(*)::int AS order_item_count
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') != ''
|
||||
OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') != ''
|
||||
OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') != ''
|
||||
GROUP BY
|
||||
o.provider,
|
||||
o.platform,
|
||||
o.shop_id,
|
||||
external_item_id,
|
||||
external_sku_code,
|
||||
external_sku_name
|
||||
ORDER BY latest_seen_at DESC, o.platform ASC, o.shop_id ASC, external_sku_code ASC
|
||||
LIMIT 200
|
||||
`,
|
||||
)
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
|
||||
observedProducts: rowsResult.rows.map((row) => {
|
||||
const item = {
|
||||
provider: String(row.provider || '').trim(),
|
||||
platform: String(row.platform || '').trim(),
|
||||
shopId: String(row.shop_id || '').trim(),
|
||||
shopName: String(row.shop_name || '').trim(),
|
||||
externalItemId: String(row.external_item_id || '').trim(),
|
||||
externalSkuCode: String(row.external_sku_code || '').trim(),
|
||||
externalSkuName: String(row.external_sku_name || '').trim(),
|
||||
latestSeenAt: row.latest_seen_at || null,
|
||||
orderItemCount: Number(row.order_item_count || 0),
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
configured: bindings.some((binding) => matchesObservedProduct(binding, item)),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAdminFulfillmentBindingConfigs(payload = {}) {
|
||||
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
|
||||
await validateAdminFulfillmentBindingConfigs(bindingsInput)
|
||||
const saved = saveOrderFulfillmentBindingConfigs(bindingsInput)
|
||||
await syncConfiguredFulfillmentBindings()
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAdminFulfillmentBindingConfigs(bindings = []) {
|
||||
if (!Array.isArray(bindings)) {
|
||||
throw createHttpError('履约配置格式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_payload',
|
||||
})
|
||||
}
|
||||
|
||||
const seenKeys = new Set()
|
||||
|
||||
for (const [index, rawBinding] of bindings.entries()) {
|
||||
if (!isPlainObject(rawBinding)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则格式不正确`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_item',
|
||||
})
|
||||
}
|
||||
|
||||
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(rawBinding.platform || '').trim()
|
||||
const shopId = String(rawBinding.shopId || '').trim()
|
||||
const skuCode = String(rawBinding.skuCode || '').trim()
|
||||
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
|
||||
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_sku_code',
|
||||
})
|
||||
}
|
||||
|
||||
if (!externalItemId && !externalSkuCode && !externalSkuName) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_match_condition',
|
||||
})
|
||||
}
|
||||
|
||||
const profile = await getFulfillmentProfileByKey(profileKey)
|
||||
if (!profile) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${profileKey}`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
|
||||
})
|
||||
}
|
||||
|
||||
const uniqueKey = [
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
externalItemId,
|
||||
externalSkuCode,
|
||||
normalizeProductName(externalSkuName),
|
||||
skuCode,
|
||||
].join('::')
|
||||
|
||||
if (seenKeys.has(uniqueKey)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
|
||||
})
|
||||
}
|
||||
|
||||
seenKeys.add(uniqueKey)
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryAdminTask(taskId) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const now = nowIso()
|
||||
@@ -934,6 +1082,55 @@ function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummar
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminFulfillmentBindingConfigItem(item) {
|
||||
const match = item?.match || {}
|
||||
return {
|
||||
provider: String(item?.provider || '').trim(),
|
||||
platform: String(item?.platform || '').trim(),
|
||||
shopId: String(item?.shopId || '').trim(),
|
||||
skuCode: String(item?.skuCode || '').trim(),
|
||||
skuName: String(item?.skuName || '').trim(),
|
||||
profileKey: String(item?.profileKey || '').trim(),
|
||||
enabled: item?.enabled !== false,
|
||||
priority: Number(item?.priority || 100),
|
||||
config: item?.config || {},
|
||||
match: {
|
||||
externalSkuCode: String(match.externalSkuCode || '').trim(),
|
||||
externalItemId: String(match.externalItemId || '').trim(),
|
||||
externalSkuName: String(match.externalSkuName || '').trim(),
|
||||
config: match.config || {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function matchesObservedProduct(binding, observed) {
|
||||
const provider = String(binding?.provider || '').trim()
|
||||
const platform = String(binding?.platform || '').trim()
|
||||
const shopId = String(binding?.shopId || '').trim()
|
||||
const match = binding?.match || {}
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
|
||||
if (provider && provider !== String(observed?.provider || '').trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (platform && platform !== String(observed?.platform || '').trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (shopId && shopId !== String(observed?.shopId || '').trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
(externalSkuCode && externalSkuCode === String(observed?.externalSkuCode || '').trim())
|
||||
|| (externalItemId && externalItemId === String(observed?.externalItemId || '').trim())
|
||||
|| (externalSkuName && externalSkuName === String(observed?.externalSkuName || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) {
|
||||
const headers = normalizeRecord(safeParseJson(item.headers_json))
|
||||
const query = normalizeRecord(safeParseJson(item.query_json))
|
||||
@@ -1666,6 +1863,10 @@ function normalizeRecord(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function extractWebhookPayload(body) {
|
||||
const normalizedBody = normalizeRecord(body)
|
||||
const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import {
|
||||
getFulfillmentProfileByKey,
|
||||
listFulfillmentProfileRequirements,
|
||||
@@ -6,6 +5,10 @@ import {
|
||||
upsertFulfillmentProfile,
|
||||
upsertSkuFulfillmentBinding,
|
||||
} from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js'
|
||||
import { normalizeProductName } from '../order/product-match-service.js'
|
||||
import { query } from '../../db/client.js'
|
||||
import { getOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
const CORE_PROFILES = [
|
||||
@@ -37,6 +40,11 @@ const CORE_PROFILES = [
|
||||
]
|
||||
|
||||
export async function ensureFulfillmentCatalogBootstrapped() {
|
||||
const profileMap = await ensureCoreProfiles()
|
||||
await syncConfiguredFulfillmentBindings(profileMap)
|
||||
}
|
||||
|
||||
async function ensureCoreProfiles() {
|
||||
const timestamp = nowIso()
|
||||
const profileMap = {}
|
||||
|
||||
@@ -64,7 +72,15 @@ export async function ensureFulfillmentCatalogBootstrapped() {
|
||||
}
|
||||
}
|
||||
|
||||
const bindingsToApply = normalizeConfiguredBindings(runtimeConfig.orders?.fulfillmentBindings)
|
||||
return profileMap
|
||||
}
|
||||
|
||||
export async function syncConfiguredFulfillmentBindings(profileMap = {}) {
|
||||
const timestamp = nowIso()
|
||||
const bindingsToApply = getOrderFulfillmentBindingConfigs()
|
||||
|
||||
await query('DELETE FROM product_match_rules')
|
||||
await query('DELETE FROM sku_fulfillment_bindings')
|
||||
|
||||
for (const binding of bindingsToApply) {
|
||||
const profile = profileMap[binding.profileKey] || await getFulfillmentProfileByKey(binding.profileKey)
|
||||
@@ -84,24 +100,30 @@ export async function ensureFulfillmentCatalogBootstrapped() {
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
|
||||
const match = binding.match || {}
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
|
||||
if (!externalSkuName && !externalItemId && !externalSkuCode) {
|
||||
continue
|
||||
}
|
||||
|
||||
await upsertProductMatchRule({
|
||||
provider: binding.provider,
|
||||
platform: binding.platform,
|
||||
shopId: binding.shopId,
|
||||
externalItemId,
|
||||
externalSkuCode,
|
||||
externalSkuName,
|
||||
externalSkuNameNormalized: normalizeProductName(externalSkuName),
|
||||
resolvedSkuCode: binding.skuCode,
|
||||
enabled: binding.enabled,
|
||||
priority: binding.priority,
|
||||
configJson: JSON.stringify(match.config || {}),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfiguredBindings(bindings) {
|
||||
if (!Array.isArray(bindings)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return bindings
|
||||
.map((binding) => ({
|
||||
skuCode: String(binding?.skuCode || '').trim(),
|
||||
provider: String(binding?.provider || '').trim(),
|
||||
platform: String(binding?.platform || '').trim(),
|
||||
shopId: String(binding?.shopId || '').trim(),
|
||||
profileKey: String(binding?.profileKey || '').trim() || 'manual_review',
|
||||
enabled: binding?.enabled !== false,
|
||||
priority: Number(binding?.priority || 100),
|
||||
config: binding?.config || {},
|
||||
}))
|
||||
.filter((binding) => binding.skuCode)
|
||||
}
|
||||
|
||||
@@ -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 : {}
|
||||
}
|
||||
|
||||
@@ -340,7 +340,16 @@ function normalizeOrderItems(payload, fallbackItems = []) {
|
||||
|
||||
return items.map((item) => {
|
||||
const source = isPlainObject(item) ? item : {}
|
||||
const skuDescriptor = parseExternalSkuDescriptor(
|
||||
pickFirstNonEmpty([
|
||||
source.sku,
|
||||
source.Sku,
|
||||
payload.sku,
|
||||
payload.Sku,
|
||||
]),
|
||||
)
|
||||
const skuCode = pickFirstNonEmpty([
|
||||
skuDescriptor.externalSkuCode,
|
||||
source.OuterSkuId,
|
||||
source.outerSkuId,
|
||||
source.outer_sku_id,
|
||||
@@ -359,18 +368,31 @@ function normalizeOrderItems(payload, fallbackItems = []) {
|
||||
payload.item_id,
|
||||
payload.itemId,
|
||||
])
|
||||
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(
|
||||
@@ -386,10 +408,65 @@ function normalizeOrderItems(payload, fallbackItems = []) {
|
||||
) || 1,
|
||||
),
|
||||
spec: source,
|
||||
snapshot: {
|
||||
externalItemId,
|
||||
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
|
||||
externalSkuName,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 isAgisoDetailSuccess(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user