// @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 '' }