541 lines
19 KiB
JavaScript
541 lines
19 KiB
JavaScript
// @ts-check
|
|
|
|
import { query } from '../../db/client.js'
|
|
import {
|
|
getAgisoMessagingDefaults,
|
|
getAgisoShopConfig,
|
|
getAgisoShopConfigMap,
|
|
getAgisoShopsFilePath,
|
|
saveAgisoMessagingConfig,
|
|
} from '../platforms/agiso/shop-config-service.js'
|
|
import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js'
|
|
import {
|
|
getOrderFulfillmentBindingConfigs,
|
|
getOrderFulfillmentBindingsFilePath,
|
|
saveOrderFulfillmentBindingConfigs,
|
|
} from '../order/fulfillment-binding-config-service.js'
|
|
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
|
|
import { createHttpError } from '../../utils/http.js'
|
|
import { formatFenToAmount } from '../../utils/money.js'
|
|
import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js'
|
|
import { normalizeProductName } from '../order/product-match-service.js'
|
|
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
|
|
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigSaveInput */
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminFulfillmentBindingConfigSaveInput} AdminFulfillmentBindingConfigSaveInput */
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminFulfillmentBindingLookupInput} AdminFulfillmentBindingLookupInput */
|
|
|
|
export async function getAdminAgisoShopConfigs() {
|
|
const configMap = getAgisoShopConfigMap()
|
|
const defaults = getAgisoMessagingDefaults()
|
|
const rowsResult = await query(
|
|
`
|
|
SELECT
|
|
shop_id,
|
|
MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name,
|
|
MAX(created_at) AS latest_seen_at,
|
|
COUNT(*)::int AS webhook_event_count
|
|
FROM webhook_events
|
|
WHERE provider = 'agiso' AND trim(shop_id) != ''
|
|
GROUP BY shop_id
|
|
ORDER BY latest_seen_at DESC, shop_id DESC
|
|
`,
|
|
)
|
|
const rows = rowsResult.rows
|
|
|
|
return {
|
|
filePath: getAgisoShopsFilePath(),
|
|
defaults: mapAdminAgisoMessagingDefaults(defaults),
|
|
shops: Object.entries(configMap)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
|
observedShops: rows.map((row) => ({
|
|
shopId: String(row.shop_id || '').trim(),
|
|
detectedShopName: String(row.detected_shop_name || '').trim(),
|
|
displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name),
|
|
latestSeenAt: row.latest_seen_at || null,
|
|
webhookEventCount: Number(row.webhook_event_count || 0),
|
|
configured: Boolean(configMap[String(row.shop_id || '').trim()]),
|
|
})),
|
|
}
|
|
}
|
|
|
|
/** @param {AdminAgisoShopConfigSaveInput} [payload] */
|
|
export function updateAdminAgisoShopConfigs(payload = /** @type {AdminAgisoShopConfigSaveInput} */ ({})) {
|
|
const rawItems = Array.isArray(payload.shops) ? payload.shops : []
|
|
const currentDefaults = getAgisoMessagingDefaults()
|
|
const currentMap = getAgisoShopConfigMap()
|
|
const nextDefaults = { ...currentDefaults }
|
|
const nextMap = {}
|
|
|
|
applyOptionalStringField(nextDefaults, 'messageTemplate', payload.defaults)
|
|
applyOptionalStringField(nextDefaults, 'autoDeliveryMessageTemplate', payload.defaults)
|
|
|
|
for (const item of rawItems) {
|
|
const shopId = String(item?.shopId || '').trim()
|
|
if (!shopId) {
|
|
continue
|
|
}
|
|
|
|
const current = currentMap[shopId] || {}
|
|
const next = { ...current }
|
|
const shopName = String(item?.shopName || '').trim()
|
|
const accessToken = String(item?.accessToken || '').trim()
|
|
const messageTemplate = String(item?.messageTemplate || '').trim()
|
|
const autoDeliveryMessageTemplate = String(item?.autoDeliveryMessageTemplate || '').trim()
|
|
const appSecret = String(item?.appSecret || '').trim()
|
|
const apiVersion = String(item?.apiVersion || '').trim()
|
|
const sendMessageEndpoint = String(item?.sendMessageEndpoint || '').trim()
|
|
|
|
if (shopName) {
|
|
next.shopName = shopName
|
|
} else {
|
|
delete next.shopName
|
|
}
|
|
if (accessToken) {
|
|
next.accessToken = accessToken
|
|
}
|
|
if (messageTemplate) {
|
|
next.messageTemplate = messageTemplate
|
|
} else if (typeof item?.messageTemplate === 'string') {
|
|
delete next.messageTemplate
|
|
}
|
|
if (autoDeliveryMessageTemplate) {
|
|
next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate
|
|
} else if (typeof item?.autoDeliveryMessageTemplate === 'string') {
|
|
delete next.autoDeliveryMessageTemplate
|
|
}
|
|
if (appSecret) {
|
|
next.appSecret = appSecret
|
|
}
|
|
if (apiVersion) {
|
|
next.apiVersion = apiVersion
|
|
}
|
|
if (sendMessageEndpoint) {
|
|
next.sendMessageEndpoint = sendMessageEndpoint
|
|
}
|
|
if (typeof item?.enabled === 'boolean') {
|
|
next.enabled = item.enabled
|
|
}
|
|
|
|
if (!next.accessToken) {
|
|
continue
|
|
}
|
|
|
|
nextMap[shopId] = next
|
|
}
|
|
|
|
const saved = saveAgisoMessagingConfig({
|
|
defaults: nextDefaults,
|
|
shops: nextMap,
|
|
})
|
|
|
|
return {
|
|
filePath: getAgisoShopsFilePath(),
|
|
defaults: mapAdminAgisoMessagingDefaults(saved.defaults),
|
|
shops: Object.entries(saved.shops)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
|
}
|
|
}
|
|
|
|
function mapAdminAgisoMessagingDefaults(defaults = {}) {
|
|
return {
|
|
messageTemplate: String(defaults.messageTemplate || '').trim(),
|
|
autoDeliveryMessageTemplate: String(defaults.autoDeliveryMessageTemplate || '').trim(),
|
|
}
|
|
}
|
|
|
|
function mapAdminAgisoShopConfigItem(shopId, config = {}) {
|
|
return {
|
|
shopId,
|
|
shopName: String(config.shopName || '').trim(),
|
|
accessToken: String(config.accessToken || '').trim(),
|
|
accessTokenMasked: maskSecret(config.accessToken),
|
|
enabled: typeof config.enabled === 'boolean' ? config.enabled : null,
|
|
messageTemplate: String(config.messageTemplate || '').trim(),
|
|
autoDeliveryMessageTemplate: String(config.autoDeliveryMessageTemplate || '').trim(),
|
|
appSecretConfigured: Boolean(String(config.appSecret || '').trim()),
|
|
apiVersion: String(config.apiVersion || '').trim(),
|
|
sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(),
|
|
}
|
|
}
|
|
|
|
function applyOptionalStringField(target, key, source) {
|
|
if (!source || typeof source[key] !== 'string') {
|
|
return
|
|
}
|
|
|
|
const value = String(source[key] || '').trim()
|
|
if (value) {
|
|
target[key] = value
|
|
return
|
|
}
|
|
|
|
delete target[key]
|
|
}
|
|
|
|
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) => mapAdminObservedProductItem({
|
|
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),
|
|
}, bindings)),
|
|
}
|
|
}
|
|
|
|
/** @param {AdminFulfillmentBindingLookupInput} [payload] */
|
|
export async function lookupAdminFulfillmentBindingOrder(payload = /** @type {AdminFulfillmentBindingLookupInput} */ ({})) {
|
|
const provider = String(payload.provider || 'agiso').trim() || 'agiso'
|
|
const platform = String(payload.platform || 'xianyu').trim() || 'xianyu'
|
|
const shopId = String(payload.shopId || '').trim()
|
|
const platformOrderId = String(payload.platformOrderId || '').trim()
|
|
|
|
if (!shopId) {
|
|
throw createHttpError('请先填写店铺 ID', {
|
|
statusCode: 400,
|
|
errorCode: 'admin_fulfillment_lookup_missing_shop_id',
|
|
})
|
|
}
|
|
|
|
if (!platformOrderId) {
|
|
throw createHttpError('请先填写平台订单号', {
|
|
statusCode: 400,
|
|
errorCode: 'admin_fulfillment_lookup_missing_platform_order_id',
|
|
})
|
|
}
|
|
|
|
if (provider !== 'agiso' || platform !== 'xianyu') {
|
|
throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', {
|
|
statusCode: 400,
|
|
errorCode: 'admin_fulfillment_lookup_platform_not_supported',
|
|
})
|
|
}
|
|
|
|
const detailResult = await enrichAgisoXianyuTradeOrder({
|
|
provider,
|
|
platform,
|
|
shopId,
|
|
shopName: '',
|
|
platformOrderId,
|
|
orderStatus: 'created',
|
|
payStatus: 'unpaid',
|
|
buyerId: '',
|
|
buyerName: '',
|
|
receiverContact: '',
|
|
totalAmount: 0,
|
|
currency: 'CNY',
|
|
paidAt: null,
|
|
rawPayload: {},
|
|
items: [],
|
|
}, {
|
|
requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`,
|
|
})
|
|
|
|
const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {}
|
|
const items = Array.isArray(detail.items) ? detail.items : []
|
|
|
|
if (items.length === 0) {
|
|
const detailReason = String(detailResult?.reason || '').trim()
|
|
const detailMessage = String(detailResult?.errorMessage || '').trim()
|
|
let message = detailMessage
|
|
|
|
if (!message && detailReason === 'missing_config') {
|
|
message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址'
|
|
}
|
|
|
|
if (!message) {
|
|
message = `未查询到订单 ${platformOrderId} 的商品明细`
|
|
}
|
|
|
|
throw createHttpError(message, {
|
|
statusCode: 404,
|
|
errorCode: 'admin_fulfillment_lookup_order_items_not_found',
|
|
})
|
|
}
|
|
|
|
const bindings = getOrderFulfillmentBindingConfigs()
|
|
const resolvedShopName = pickFirstNonEmpty([
|
|
detail.shopName,
|
|
getAgisoShopConfig(shopId)?.shopName,
|
|
])
|
|
|
|
return {
|
|
order: {
|
|
provider,
|
|
platform,
|
|
shopId,
|
|
shopName: resolvedShopName,
|
|
platformOrderId,
|
|
buyerName: String(detail.buyerName || '').trim(),
|
|
totalAmountFen: Number(detail.totalAmount || 0),
|
|
totalAmount: formatFenToAmount(detail.totalAmount),
|
|
paidAt: detail.paidAt || null,
|
|
enriched: Boolean(detailResult?.enriched),
|
|
enrichReason: String(detailResult?.reason || '').trim(),
|
|
errorMessage: String(detailResult?.errorMessage || '').trim(),
|
|
},
|
|
items: items.map((item, index) => {
|
|
const observed = {
|
|
provider,
|
|
platform,
|
|
shopId,
|
|
shopName: resolvedShopName,
|
|
externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]),
|
|
externalSkuCode: pickFirstNonEmpty([item?.externalSkuCode, item?.skuCode, item?.externalItemId, item?.itemId]),
|
|
externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]),
|
|
latestSeenAt: null,
|
|
orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1),
|
|
}
|
|
|
|
return {
|
|
lineId: [
|
|
platformOrderId,
|
|
index + 1,
|
|
observed.externalSkuCode || 'na',
|
|
observed.externalItemId || 'na',
|
|
].join(':'),
|
|
itemTitle: pickFirstNonEmpty([
|
|
item?.skuName,
|
|
item?.externalSkuName,
|
|
item?.externalSkuCode,
|
|
item?.externalItemId,
|
|
]),
|
|
quantity: observed.orderItemCount,
|
|
...mapAdminObservedProductItem(observed, bindings),
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
/** @param {AdminFulfillmentBindingConfigSaveInput} [payload] */
|
|
export async function updateAdminFulfillmentBindingConfigs(
|
|
payload = /** @type {AdminFulfillmentBindingConfigSaveInput} */ ({}),
|
|
) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
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())
|
|
)
|
|
}
|
|
|
|
function mapAdminObservedProductItem(item, bindings = []) {
|
|
const matchedBinding = findMatchingObservedBinding(bindings, item)
|
|
|
|
return {
|
|
provider: String(item?.provider || '').trim(),
|
|
platform: String(item?.platform || '').trim(),
|
|
shopId: String(item?.shopId || '').trim(),
|
|
shopName: String(item?.shopName || '').trim(),
|
|
externalItemId: String(item?.externalItemId || '').trim(),
|
|
externalSkuCode: String(item?.externalSkuCode || '').trim(),
|
|
externalSkuName: String(item?.externalSkuName || '').trim(),
|
|
latestSeenAt: item?.latestSeenAt || null,
|
|
orderItemCount: Number(item?.orderItemCount || 0),
|
|
configured: Boolean(matchedBinding),
|
|
matchedBinding: matchedBinding
|
|
? {
|
|
skuCode: String(matchedBinding.skuCode || '').trim(),
|
|
skuName: String(matchedBinding.skuName || '').trim(),
|
|
profileKey: String(matchedBinding.profileKey || '').trim(),
|
|
}
|
|
: null,
|
|
}
|
|
}
|
|
|
|
function findMatchingObservedBinding(bindings, observed) {
|
|
return (Array.isArray(bindings) ? bindings : []).find((binding) => matchesObservedProduct(binding, observed)) || null
|
|
}
|
|
|
|
function maskSecret(value) {
|
|
const normalized = String(value || '').trim()
|
|
if (!normalized) {
|
|
return ''
|
|
}
|
|
|
|
if (normalized.length <= 10) {
|
|
return `${normalized.slice(0, 2)}****${normalized.slice(-2)}`
|
|
}
|
|
|
|
return `${normalized.slice(0, 6)}****${normalized.slice(-6)}`
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function pickFirstNonEmpty(values) {
|
|
for (const value of values) {
|
|
const normalized = String(value || '').trim()
|
|
if (normalized) {
|
|
return normalized
|
|
}
|
|
}
|
|
|
|
return ''
|
|
}
|