优化了一些文件 增加多店铺

This commit is contained in:
yml
2026-04-09 01:31:47 +08:00
parent c2ec3aa6d2
commit e4ab1f559e
43 changed files with 1839 additions and 91 deletions
@@ -0,0 +1,9 @@
## Agiso Platform Services
Keep Agiso-wide shared helpers in this directory.
Per-platform integrations live in subdirectories:
- `xianyu/`
- `pdd/`
- `taobao/`
@@ -0,0 +1,3 @@
## PDD
Put all Agiso PDD-specific service integrations here.
@@ -0,0 +1,103 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js'
const AGISO_SHOPS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'agiso-shops.json')
export function getAgisoShopsFilePath() {
return AGISO_SHOPS_FILE_PATH
}
export function getAgisoShopConfigMap() {
const envConfig = normalizeAgisoShopConfigMap(runtimeConfig.platforms?.agiso?.messaging?.shops || {})
const fileConfig = loadAgisoShopConfigMapFromFile()
return {
...envConfig,
...fileConfig,
}
}
export function getAgisoShopConfig(shopId) {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId) {
return null
}
return getAgisoShopConfigMap()[normalizedShopId] || null
}
export function saveAgisoShopConfigMap(rawValue) {
const normalized = normalizeAgisoShopConfigMap(rawValue)
fs.mkdirSync(path.dirname(AGISO_SHOPS_FILE_PATH), { recursive: true })
fs.writeFileSync(AGISO_SHOPS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
function loadAgisoShopConfigMapFromFile() {
if (!fs.existsSync(AGISO_SHOPS_FILE_PATH)) {
return {}
}
try {
const rawText = fs.readFileSync(AGISO_SHOPS_FILE_PATH, 'utf8')
const parsed = JSON.parse(rawText)
return normalizeAgisoShopConfigMap(parsed)
} catch {
return {}
}
}
function normalizeAgisoShopConfigMap(rawValue) {
const output = {}
for (const [shopId, config] of Object.entries(rawValue || {})) {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId || !isPlainObject(config)) {
continue
}
const next = {}
const enabled = normalizeBooleanLike(config.enabled)
if (enabled !== null) {
next.enabled = enabled
}
for (const key of ['shopName', 'accessToken', 'messageTemplate', 'appSecret', 'apiVersion', 'sendMessageEndpoint', 'tradeDetailEndpoint']) {
const value = String(config[key] || '').trim()
if (value) {
next[key] = value
}
}
output[normalizedShopId] = next
}
return output
}
function normalizeBooleanLike(value) {
if (typeof value === 'boolean') {
return value
}
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) {
return null
}
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
}
return null
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,3 @@
## Taobao
Put all Agiso Taobao-specific service integrations here.
@@ -0,0 +1,3 @@
## Xianyu
Put all Agiso Xianyu-specific service integrations here.
@@ -0,0 +1,262 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { getAgisoShopConfigMap } from '../shop-config-service.js'
import {
createMessageDelivery,
findLatestSuccessfulMessageDeliveryByTask,
updateMessageDelivery,
} from '../../../../repositories/message-delivery-repo.js'
import { nowIso } from '../../../../utils/time.js'
const AGISO_XIANYU_MESSAGE_CHANNEL = 'agiso_im'
export async function ensureAgisoXianyuClaimMessageDeliveredForTask({ order, task, claimUrl, expiredAt }) {
if (!order || !task || !claimUrl) {
return { sent: false, skipped: true, reason: 'missing_message_context' }
}
const config = resolveAgisoXianyuMessagingConfig(order)
if (!config.enabled) {
return { sent: false, skipped: true, reason: 'messaging_disabled' }
}
const endpoint = String(config.sendMessageEndpoint || '').trim()
const accessToken = String(config.accessToken || '').trim()
const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim()
if (!endpoint || !accessToken || !appSecret) {
return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' }
}
const successful = findLatestSuccessfulMessageDeliveryByTask(task.id, AGISO_XIANYU_MESSAGE_CHANNEL)
if (successful) {
return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id }
}
const messageContent = renderAgisoClaimMessage({
order,
task,
claimUrl,
expiredAt,
template: config.messageTemplate,
shopName: config.shopName,
})
const url = buildRequestUrl(endpoint)
const requestBody = buildRequestBody({
tid: String(order.platform_order_id || ''),
msg: messageContent,
appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken,
apiVersion: String(config.apiVersion || '1').trim() || '1',
})
const createdAt = nowIso()
const delivery = createMessageDelivery({
provider: 'agiso',
platform: String(order.platform || '').trim() || 'unknown',
shopId: String(order.shop_id || '').trim(),
shopName: String(order.shop_name || '').trim(),
channel: AGISO_XIANYU_MESSAGE_CHANNEL,
orderId: order.id,
taskId: task.id,
platformOrderId: order.platform_order_id,
recipientKey: order.platform_order_id,
messageContent,
claimUrl,
status: 'pending',
requestUrl: url,
requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)),
requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)),
responseStatus: 0,
responseJson: '{}',
errorMessage: '',
sentAt: null,
createdAt,
updatedAt: createdAt,
})
try {
const response = await fetch(url, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
})
const rawText = await response.text()
const parsed = safeParseJson(rawText)
const success = isAgisoSendSuccess(response.status, parsed)
const updated = updateMessageDelivery(delivery.id, {
status: success ? 'success' : 'failed',
response_status: response.status,
response_json: JSON.stringify(parsed ?? { rawText }),
error_message: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status),
sent_at: success ? nowIso() : null,
updated_at: nowIso(),
})
return {
sent: success,
skipped: false,
deliveryId: updated?.id || delivery.id,
responseStatus: response.status,
response: parsed ?? { rawText },
errorMessage: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status),
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '发送消息失败')
updateMessageDelivery(delivery.id, {
status: 'failed',
response_status: 0,
response_json: '{}',
error_message: message,
sent_at: null,
updated_at: nowIso(),
})
return {
sent: false,
skipped: false,
deliveryId: delivery.id,
errorMessage: message,
}
}
}
function resolveAgisoXianyuMessagingConfig(order) {
const baseConfig = runtimeConfig.platforms?.agiso?.messaging || {}
const shopId = String(order?.shop_id || '').trim()
const shopConfigs = getAgisoShopConfigMap()
const shopConfig = shopId && isPlainObject(shopConfigs[shopId]) ? shopConfigs[shopId] : {}
return {
...baseConfig,
...shopConfig,
}
}
function buildRequestUrl(endpoint) {
return new URL(endpoint).toString()
}
function buildRequestHeaders({ accessToken, apiVersion }) {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({ tid, msg, appSecret }) {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload = {
tid,
msg,
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params, appSecret) {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template, shopName = '' }) {
const source = String(template || '').trim() || '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}'
const resolvedShopName = String(shopName || order.shop_name || order.shop_id || '').trim()
return source
.replaceAll('{platformOrderId}', String(order.platform_order_id || ''))
.replaceAll('{taskNo}', String(task.task_no || ''))
.replaceAll('{shopName}', resolvedShopName)
.replaceAll('{shopId}', String(order.shop_id || ''))
.replaceAll('{claimUrl}', claimUrl)
.replaceAll('{expiredAt}', String(expiredAt || '尽快'))
}
function isAgisoSendSuccess(statusCode, payload) {
if (statusCode < 200 || statusCode >= 300) {
return false
}
if (!payload || typeof payload !== 'object') {
return true
}
if (payload.IsSuccess === true) {
return true
}
if (Number(payload.Error_Code) === 0) {
return true
}
if (Number(payload.code) === 0) {
return true
}
return false
}
function resolveAgisoErrorMessage(payload, rawText, statusCode) {
if (payload && typeof payload === 'object') {
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼发消息失败,HTTP ${statusCode}`
}
function safeParseJson(rawText) {
const normalized = String(rawText || '').trim()
if (!normalized) {
return null
}
try {
return JSON.parse(normalized)
} catch {
return null
}
}
function maskHeadersForStorage(headers) {
const output = { ...headers }
if (output.Authorization) {
output.Authorization = '[masked]'
}
return output
}
function maskBodyForStorage(body) {
const output = { ...body }
if (output.sign) {
output.sign = '[masked]'
}
return output
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,497 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { getAgisoShopConfig } from '../shop-config-service.js'
import { logWebhook } from '../../../../utils/logger.js'
import { nowIso } from '../../../../utils/time.js'
import { parseAmountToFen } from '../../../../utils/money.js'
import { parseJsonObject } from '../../../../utils/json.js'
const DEFAULT_DETAIL_TIMEOUT_MS = 5000
export async function enrichAgisoXianyuTradeOrder(parsed, { requestId = '' } = {}) {
if (!shouldHydrateAgisoXianyuTradeOrder(parsed)) {
return { parsed, enriched: false, reason: 'not_needed' }
}
const config = resolveAgisoXianyuTradeDetailConfig(parsed.shopId)
if (!config.endpoint || !config.accessToken || !config.appSecret) {
logWebhook('[agiso/xianyu/order-detail]', '跳过 Agiso 咸鱼订单详情补查:缺少必要配置', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
hasEndpoint: Boolean(config.endpoint),
hasAccessToken: Boolean(config.accessToken),
hasAppSecret: Boolean(config.appSecret),
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'missing_config' }
}
const requestBody = buildRequestBody({
platformOrderId: parsed.platformOrderId,
appSecret: config.appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken: config.accessToken,
apiVersion: config.apiVersion,
})
logWebhook('[agiso/xianyu/order-detail]', '开始补查 Agiso 咸鱼订单详情', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
endpoint: config.endpoint,
})
try {
const response = await fetchWithTimeout(config.endpoint, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
}, config.timeoutMs)
const rawText = await response.text()
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true })
const detailPayload = extractAgisoDetailPayload(payload)
const detailAmountFen = resolveTotalAmountFen(detailPayload)
if (response.status < 200 || response.status >= 300) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查失败', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
responseStatus: response.status,
errorMessage: message,
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'http_error', errorMessage: message }
}
if (!isAgisoDetailSuccess(payload)) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查返回业务失败', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
responseStatus: response.status,
errorMessage: message,
response: payload,
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'business_error', errorMessage: message }
}
if (!detailPayload || typeof detailPayload !== 'object' || Object.keys(detailPayload).length === 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查未返回可用订单体', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
responseStatus: response.status,
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'empty_detail_payload' }
}
if (detailAmountFen <= 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情已返回,但仍未解析出金额', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
responseStatus: response.status,
detailKeys: Object.keys(detailPayload),
}, { level: 'warn' })
return {
parsed: mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount: true }),
enriched: false,
reason: 'amount_still_missing',
}
}
const merged = mergeEnrichedTrade(parsed, detailPayload)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查成功', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
totalAmountFen: merged.totalAmount,
paidAt: merged.paidAt,
})
return {
parsed: merged,
enriched: true,
totalAmountFen: merged.totalAmount,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼订单详情补查失败')
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查异常', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
errorMessage: message,
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'request_failed', errorMessage: message }
}
}
function shouldHydrateAgisoXianyuTradeOrder(parsed) {
return parsed?.provider === 'agiso'
&& parsed?.platform === 'xianyu'
&& String(parsed?.platformOrderId || '').trim()
&& Number(parsed?.totalAmount || 0) <= 0
}
function resolveAgisoXianyuTradeDetailConfig(shopId) {
const baseConfig = runtimeConfig.platforms?.agiso?.tradeDetail || {}
const shopConfig = getAgisoShopConfig(shopId) || {}
return {
endpoint: String(shopConfig.tradeDetailEndpoint || baseConfig.endpoint || '').trim(),
apiVersion: String(shopConfig.tradeDetailApiVersion || baseConfig.apiVersion || '1').trim() || '1',
accessToken: String(shopConfig.accessToken || runtimeConfig.platforms?.agiso?.messaging?.accessToken || '').trim(),
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
timeoutMs: normalizePositiveInteger(shopConfig.tradeDetailTimeoutMs || baseConfig.timeoutMs, DEFAULT_DETAIL_TIMEOUT_MS),
}
}
function buildRequestHeaders({ accessToken, apiVersion }) {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({ platformOrderId, appSecret }) {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload = {
tid: String(platformOrderId || '').trim(),
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params, appSecret) {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function extractAgisoDetailPayload(payload) {
if (!isPlainObject(payload)) {
return {}
}
for (const key of ['Data', 'data', 'Result', 'result', 'Trade', 'trade', 'Order', 'order']) {
if (isPlainObject(payload[key])) {
return payload[key]
}
}
return payload
}
function resolveTotalAmountFen(payload) {
const fenAmount = normalizeFenInteger(pickFirstNonEmpty([
payload.payment,
payload.Payment,
payload.post_fee,
payload.postFee,
payload.item?.price,
]))
if (fenAmount > 0) {
return fenAmount
}
return parseAmountToFen(pickFirstNonEmpty([
payload.total_fee,
payload.totalFee,
payload.TotalFee,
payload.pay_fee,
payload.payFee,
payload.PayFee,
payload.actual_fee,
payload.actualFee,
payload.ActualFee,
payload.total_amount,
payload.totalAmount,
payload.Amount,
payload.amount,
]))
}
function resolvePaidAt(payload, fallbackValue) {
const providerPaidAt = normalizeProviderDateTime(
pickFirstNonEmpty([
payload.paid_at,
payload.paidAt,
payload.pay_time,
payload.payTime,
payload.PayTime,
fallbackValue,
]),
)
if (providerPaidAt) {
return providerPaidAt
}
const rawStatus = normalizeInteger(
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
)
if (rawStatus === 2 || rawStatus === 3 || rawStatus === 4) {
return nowIso()
}
return null
}
function mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount = false } = {}) {
const mergedPayload = {
...parsed.rawPayload,
_agisoTradeDetail: detailPayload,
}
const totalAmountFen = resolveTotalAmountFen(detailPayload)
return {
...parsed,
buyerId: pickFirstNonEmpty([
parsed.buyerId,
detailPayload.encryption_buyer_id,
detailPayload.buyer_id,
detailPayload.buyerId,
detailPayload.BuyerId,
detailPayload.BuyerOpenUid,
detailPayload.buyer_open_uid,
]),
buyerName: pickFirstNonEmpty([
parsed.buyerName,
detailPayload.buyer_name,
detailPayload.buyerName,
detailPayload.BuyerName,
detailPayload.buyer_nick,
detailPayload.nick,
detailPayload.BuyerNick,
]),
receiverContact: pickFirstNonEmpty([
parsed.receiverContact,
detailPayload.receiver_contact,
detailPayload.receiverContact,
detailPayload.receiver_mobile,
detailPayload.receiverMobile,
detailPayload.mobile,
detailPayload.phone,
]),
shopName: pickFirstNonEmpty([
parsed.shopName,
detailPayload.shop_name,
detailPayload.shopName,
detailPayload.ShopName,
detailPayload.seller_name,
detailPayload.sellerName,
detailPayload.seller_nick,
detailPayload.sellerNick,
detailPayload.SellerNick,
]),
totalAmount: keepOriginalAmount ? parsed.totalAmount : (totalAmountFen || parsed.totalAmount),
paidAt: parsed.paidAt || resolvePaidAt(detailPayload, parsed.paidAt),
rawPayload: mergedPayload,
items: normalizeOrderItems(mergedPayload, parsed.items),
}
}
function normalizeOrderItems(payload, fallbackItems = []) {
const candidates = [
payload.item ? [payload.item] : null,
payload.items,
payload.Items,
payload.orders,
payload.Orders,
payload.order_list,
payload.OrderList,
]
const items = candidates.find((item) => Array.isArray(item) && item.length > 0)
if (!Array.isArray(items) || items.length === 0) {
return Array.isArray(fallbackItems) ? fallbackItems : []
}
return items.map((item) => {
const source = isPlainObject(item) ? item : {}
const skuCode = pickFirstNonEmpty([
source.OuterSkuId,
source.outerSkuId,
source.outer_sku_id,
source.OuterIid,
source.outerIid,
source.outer_iid,
source.sku_code,
source.skuCode,
source.goods_sku,
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
source.NumIid,
source.num_iid,
payload.item_id,
payload.itemId,
])
return {
skuCode,
skuName: pickFirstNonEmpty([
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
]),
quantity: Math.max(
1,
normalizeInteger(
pickFirstNonEmpty([
source.quantity,
source.num,
source.Num,
source.buy_amount,
payload.quantity,
payload.num,
payload.Num,
]),
) || 1,
),
spec: source,
}
})
}
function isAgisoDetailSuccess(payload) {
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
return false
}
if (payload.IsSuccess === true) {
return true
}
if (Number(payload.Error_Code) === 0) {
return true
}
if (Number(payload.code) === 0) {
return true
}
if (Number(payload.success) === 1 || payload.success === true) {
return true
}
return false
}
function resolveAgisoDetailErrorMessage(payload, rawText, statusCode) {
if (payload && typeof payload === 'object') {
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼订单详情接口失败,HTTP ${statusCode}`
}
async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('Agiso 咸鱼订单详情请求超时')), timeoutMs)
try {
return await fetch(url, {
...options,
signal: controller.signal,
})
} finally {
clearTimeout(timer)
}
}
function normalizeProviderDateTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return null
}
if (/^\d{10,13}$/.test(normalized)) {
const timestamp = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000
if (Number.isFinite(timestamp)) {
return new Date(timestamp).toISOString()
}
}
const isoLike = normalized.replace(' ', 'T')
const parsed = Date.parse(isoLike)
if (Number.isNaN(parsed)) {
return null
}
return new Date(parsed).toISOString()
}
function normalizeInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) ? Math.round(parsed) : 0
}
function normalizePositiveInteger(value, fallbackValue) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallbackValue
}
function normalizeFenInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
}
function pickFirstNonEmpty(values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}