842 lines
32 KiB
JavaScript
842 lines
32 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 { mapKhhaoOrderPreviewList } from '../platforms/khhao/order-mapper-service.js'
|
|
import { queryKhhaoOrderList } from '../platforms/khhao/order-query-service.js'
|
|
import { syncKhhaoOrders } from '../platforms/khhao/order-sync-service.js'
|
|
import { getKhhaoSourceConfig, getKhhaoSourcesFilePath, saveKhhaoSourceConfig } from '../platforms/khhao/source-config-service.js'
|
|
import { ensureKhhaoSyncBaseline, getKhhaoSyncState, getKhhaoSyncStateFilePath } from '../platforms/khhao/sync-state-service.js'
|
|
import { loginKhhaoSession } from '../platforms/khhao/session-service.js'
|
|
import {
|
|
getCloudtentaclesSourceConfig,
|
|
getCloudtentaclesSourcesFilePath,
|
|
saveCloudtentaclesSourceConfig,
|
|
} from '../platforms/cloudtentacles/source-config-service.js'
|
|
import {
|
|
loginCloudtentaclesSession,
|
|
sendCloudtentaclesSmsCode,
|
|
validateCloudtentaclesSession,
|
|
} from '../platforms/cloudtentacles/session-service.js'
|
|
import { normalizeAgisoMessageTemplate } from '../platforms/agiso/xianyu/message-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').AdminKhhaoSourceConfigInput} AdminKhhaoSourceConfigInput */
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoOrderQueryInput} AdminKhhaoOrderQueryInput */
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoTestLoginInput} AdminKhhaoTestLoginInput */
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesSourceConfigInput} AdminCloudtentaclesSourceConfigInput */
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesSendSmsCodeInput} AdminCloudtentaclesSendSmsCodeInput */
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesTestLoginInput} AdminCloudtentaclesTestLoginInput */
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesValidateSessionInput} AdminCloudtentaclesValidateSessionInput */
|
|
/** @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: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()),
|
|
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(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: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()),
|
|
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(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] = normalizeAgisoMessageTemplate(value)
|
|
return
|
|
}
|
|
|
|
delete target[key]
|
|
}
|
|
|
|
export function getAdminKhhaoSourceConfig() {
|
|
const config = getKhhaoSourceConfig()
|
|
const syncState = getKhhaoSyncState()
|
|
|
|
return {
|
|
filePath: getKhhaoSourcesFilePath(),
|
|
stateFilePath: getKhhaoSyncStateFilePath(),
|
|
source: {
|
|
enabled: config.enabled !== false,
|
|
baseUrl: String(config.baseUrl || '').trim(),
|
|
username: String(config.username || '').trim(),
|
|
password: String(config.password || '').trim(),
|
|
maxCaptchaAttempts: Number(config.maxCaptchaAttempts || 3) || 3,
|
|
autoSync: {
|
|
enabled: config.autoSync?.enabled === true,
|
|
intervalMinutes: Number(config.autoSync?.intervalMinutes || 3) || 3,
|
|
pages: Number(config.autoSync?.pages || 2) || 2,
|
|
pageSize: Number(config.autoSync?.pageSize || 20) || 20,
|
|
},
|
|
},
|
|
syncState: {
|
|
syncFromCreatedAt: String(syncState.syncFromCreatedAt || '').trim(),
|
|
lastRunStartedAt: String(syncState.lastRunStartedAt || '').trim(),
|
|
lastRunFinishedAt: String(syncState.lastRunFinishedAt || '').trim(),
|
|
lastRunStatus: String(syncState.lastRunStatus || '').trim(),
|
|
lastErrorMessage: String(syncState.lastErrorMessage || '').trim(),
|
|
fetchedCount: Number(syncState.fetchedCount || 0),
|
|
syncedCount: Number(syncState.syncedCount || 0),
|
|
ignoredCount: Number(syncState.ignoredCount || 0),
|
|
},
|
|
}
|
|
}
|
|
|
|
/** @param {AdminKhhaoSourceConfigInput} [payload] */
|
|
export function updateAdminKhhaoSourceConfig(payload = /** @type {AdminKhhaoSourceConfigInput} */ ({})) {
|
|
const saved = saveKhhaoSourceConfig({
|
|
enabled: payload.enabled !== false,
|
|
baseUrl: String(payload.baseUrl || '').trim() || 'https://admin.khhao.com',
|
|
username: String(payload.username || '').trim(),
|
|
password: String(payload.password || '').trim(),
|
|
maxCaptchaAttempts: Number(payload.maxCaptchaAttempts || 3) || 3,
|
|
autoSync: {
|
|
enabled: payload.autoSync?.enabled === true,
|
|
intervalMinutes: Number(payload.autoSync?.intervalMinutes || 3) || 3,
|
|
pages: Number(payload.autoSync?.pages || 2) || 2,
|
|
pageSize: Number(payload.autoSync?.pageSize || 20) || 20,
|
|
},
|
|
})
|
|
const syncState = saved.autoSync?.enabled ? ensureKhhaoSyncBaseline() : getKhhaoSyncState()
|
|
|
|
return {
|
|
filePath: getKhhaoSourcesFilePath(),
|
|
stateFilePath: getKhhaoSyncStateFilePath(),
|
|
source: {
|
|
enabled: saved.enabled !== false,
|
|
baseUrl: String(saved.baseUrl || '').trim(),
|
|
username: String(saved.username || '').trim(),
|
|
password: String(saved.password || '').trim(),
|
|
maxCaptchaAttempts: Number(saved.maxCaptchaAttempts || 3) || 3,
|
|
autoSync: {
|
|
enabled: saved.autoSync?.enabled === true,
|
|
intervalMinutes: Number(saved.autoSync?.intervalMinutes || 3) || 3,
|
|
pages: Number(saved.autoSync?.pages || 2) || 2,
|
|
pageSize: Number(saved.autoSync?.pageSize || 20) || 20,
|
|
},
|
|
},
|
|
syncState: {
|
|
syncFromCreatedAt: String(syncState.syncFromCreatedAt || '').trim(),
|
|
lastRunStartedAt: String(syncState.lastRunStartedAt || '').trim(),
|
|
lastRunFinishedAt: String(syncState.lastRunFinishedAt || '').trim(),
|
|
lastRunStatus: String(syncState.lastRunStatus || '').trim(),
|
|
lastErrorMessage: String(syncState.lastErrorMessage || '').trim(),
|
|
fetchedCount: Number(syncState.fetchedCount || 0),
|
|
syncedCount: Number(syncState.syncedCount || 0),
|
|
ignoredCount: Number(syncState.ignoredCount || 0),
|
|
},
|
|
}
|
|
}
|
|
|
|
/** @param {AdminKhhaoTestLoginInput} [payload] */
|
|
export async function testAdminKhhaoLogin(payload = /** @type {AdminKhhaoTestLoginInput} */ ({})) {
|
|
const savedSource = getKhhaoSourceConfig()
|
|
const session = await loginKhhaoSession({
|
|
baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(),
|
|
username: String(payload.username || savedSource.username || '').trim(),
|
|
password: String(payload.password || savedSource.password || '').trim(),
|
|
maxCaptchaAttempts: payload.maxCaptchaAttempts || savedSource.maxCaptchaAttempts,
|
|
includeImageBase64: payload.includeImageBase64,
|
|
requestId: `admin-khhao-test-login:${String(payload.username || savedSource.username || '').trim() || 'anonymous'}`,
|
|
})
|
|
|
|
return {
|
|
baseUrl: session.baseUrl,
|
|
username: session.username,
|
|
loggedInAt: session.loggedInAt,
|
|
attempt: session.attempt,
|
|
responseMessage: session.responseMessage,
|
|
captcha: {
|
|
recognizedText: session.captchaText,
|
|
imageBase64: session.captchaImageBase64,
|
|
},
|
|
session: {
|
|
cookieKeys: Object.keys(session.cookieMap),
|
|
cookieCount: Object.keys(session.cookieMap).length,
|
|
cookieHeaderMasked: maskSecret(session.cookieHeader),
|
|
},
|
|
}
|
|
}
|
|
|
|
/** @param {AdminKhhaoOrderQueryInput} [payload] */
|
|
export async function queryAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQueryInput} */ ({})) {
|
|
const savedSource = getKhhaoSourceConfig()
|
|
const session = await loginKhhaoSession({
|
|
baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(),
|
|
username: String(payload.username || savedSource.username || '').trim(),
|
|
password: String(payload.password || savedSource.password || '').trim(),
|
|
maxCaptchaAttempts: payload.maxCaptchaAttempts || savedSource.maxCaptchaAttempts,
|
|
requestId: `admin-khhao-query-orders:${String(payload.username || savedSource.username || '').trim() || 'anonymous'}`,
|
|
})
|
|
|
|
const result = await queryKhhaoOrderList({
|
|
baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(),
|
|
page: payload.page,
|
|
limit: payload.limit,
|
|
session,
|
|
})
|
|
const previews = mapKhhaoOrderPreviewList(result.items)
|
|
|
|
return {
|
|
baseUrl: session.baseUrl,
|
|
page: result.page,
|
|
limit: result.limit,
|
|
total: result.total,
|
|
itemCount: result.items.length,
|
|
items: previews,
|
|
rawItems: result.items,
|
|
}
|
|
}
|
|
|
|
/** @param {AdminKhhaoOrderQueryInput} [payload] */
|
|
export async function syncAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQueryInput} */ ({})) {
|
|
const savedSource = getKhhaoSourceConfig()
|
|
return syncKhhaoOrders({
|
|
...payload,
|
|
baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(),
|
|
username: String(payload.username || savedSource.username || '').trim(),
|
|
password: String(payload.password || savedSource.password || '').trim(),
|
|
maxCaptchaAttempts: payload.maxCaptchaAttempts || savedSource.maxCaptchaAttempts,
|
|
})
|
|
}
|
|
|
|
export function getAdminCloudtentaclesSourceConfig() {
|
|
const config = getCloudtentaclesSourceConfig()
|
|
|
|
return {
|
|
filePath: getCloudtentaclesSourcesFilePath(),
|
|
source: {
|
|
enabled: config.enabled !== false,
|
|
baseUrl: String(config.baseUrl || '').trim(),
|
|
username: String(config.username || '').trim(),
|
|
password: String(config.password || '').trim(),
|
|
phone: String(config.phone || '').trim(),
|
|
deviceId: String(config.deviceId || '-').trim() || '-',
|
|
deviceType: Number(config.deviceType || 0),
|
|
},
|
|
}
|
|
}
|
|
|
|
/** @param {AdminCloudtentaclesSourceConfigInput} [payload] */
|
|
export function updateAdminCloudtentaclesSourceConfig(payload = /** @type {AdminCloudtentaclesSourceConfigInput} */ ({})) {
|
|
const saved = saveCloudtentaclesSourceConfig({
|
|
enabled: payload.enabled !== false,
|
|
baseUrl: String(payload.baseUrl || '').trim() || 'https://123.207.217.176',
|
|
username: String(payload.username || '').trim(),
|
|
password: String(payload.password || '').trim(),
|
|
phone: String(payload.phone || '').trim(),
|
|
deviceId: String(payload.deviceId || '-').trim() || '-',
|
|
deviceType: Number(payload.deviceType || 0),
|
|
})
|
|
|
|
return {
|
|
filePath: getCloudtentaclesSourcesFilePath(),
|
|
source: {
|
|
enabled: saved.enabled !== false,
|
|
baseUrl: String(saved.baseUrl || '').trim(),
|
|
username: String(saved.username || '').trim(),
|
|
password: String(saved.password || '').trim(),
|
|
phone: String(saved.phone || '').trim(),
|
|
deviceId: String(saved.deviceId || '-').trim() || '-',
|
|
deviceType: Number(saved.deviceType || 0),
|
|
},
|
|
}
|
|
}
|
|
|
|
/** @param {AdminCloudtentaclesSendSmsCodeInput} [payload] */
|
|
export async function sendAdminCloudtentaclesSmsCode(payload = /** @type {AdminCloudtentaclesSendSmsCodeInput} */ ({})) {
|
|
const savedSource = getCloudtentaclesSourceConfig()
|
|
const result = await sendCloudtentaclesSmsCode({
|
|
baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']),
|
|
username: pickFirstNonEmpty([payload.username, savedSource.username]),
|
|
phone: pickFirstNonEmpty([payload.phone, savedSource.phone]),
|
|
deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']),
|
|
deviceType: payload.deviceType ?? savedSource.deviceType,
|
|
})
|
|
|
|
return {
|
|
...result,
|
|
phoneMasked: maskPhone(result.phone),
|
|
}
|
|
}
|
|
|
|
/** @param {AdminCloudtentaclesTestLoginInput} [payload] */
|
|
export async function testAdminCloudtentaclesLogin(payload = /** @type {AdminCloudtentaclesTestLoginInput} */ ({})) {
|
|
const savedSource = getCloudtentaclesSourceConfig()
|
|
const session = await loginCloudtentaclesSession({
|
|
baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']),
|
|
username: pickFirstNonEmpty([payload.username, savedSource.username]),
|
|
password: pickFirstNonEmpty([payload.password, savedSource.password]),
|
|
phone: pickFirstNonEmpty([payload.phone, savedSource.phone]),
|
|
code: String(payload.code || '').trim(),
|
|
deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']),
|
|
deviceType: payload.deviceType ?? savedSource.deviceType,
|
|
})
|
|
|
|
return {
|
|
baseUrl: session.baseUrl,
|
|
username: session.username,
|
|
phoneMasked: maskPhone(session.phone),
|
|
loggedInAt: session.loggedInAt,
|
|
responseMessage: session.responseMessage,
|
|
token: session.token,
|
|
session: {
|
|
tokenMasked: maskSecret(session.token),
|
|
permissionCount: session.permissions.length,
|
|
permissions: session.permissions,
|
|
},
|
|
userInfo: session.userInfo,
|
|
asset: session.asset,
|
|
}
|
|
}
|
|
|
|
/** @param {AdminCloudtentaclesValidateSessionInput} [payload] */
|
|
export async function validateAdminCloudtentaclesSession(payload = /** @type {AdminCloudtentaclesValidateSessionInput} */ ({})) {
|
|
const savedSource = getCloudtentaclesSourceConfig()
|
|
const session = await validateCloudtentaclesSession({
|
|
baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']),
|
|
token: String(payload.token || '').trim(),
|
|
deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']),
|
|
deviceType: payload.deviceType ?? savedSource.deviceType,
|
|
})
|
|
|
|
return {
|
|
baseUrl: session.baseUrl,
|
|
loggedInAt: session.loggedInAt,
|
|
session: {
|
|
tokenMasked: maskSecret(session.token),
|
|
permissionCount: session.permissions.length,
|
|
permissions: session.permissions,
|
|
},
|
|
userInfo: session.userInfo,
|
|
asset: session.asset,
|
|
}
|
|
}
|
|
|
|
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 maskPhone(value) {
|
|
const normalized = String(value || '').trim()
|
|
if (!normalized) {
|
|
return ''
|
|
}
|
|
|
|
if (normalized.length < 7) {
|
|
return normalized
|
|
}
|
|
|
|
return `${normalized.slice(0, 3)}****${normalized.slice(-4)}`
|
|
}
|
|
|
|
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 ''
|
|
}
|