补全一些基础信息

This commit is contained in:
yml2213
2026-05-13 20:18:58 +08:00
parent c2fd9078b9
commit a95e48b708
23 changed files with 1237 additions and 89 deletions
@@ -0,0 +1,41 @@
// @ts-check
import {
failOpen91Order,
listOpen91Orders,
retryOpen91Order,
} from '../../platforms/ninetyone/order-service.js'
/** @param {{ page?: number | string, pageSize?: number | string, status?: string }} [query] */
export async function getAdminNinetyoneOrders(query = {}) {
return listOpen91Orders({
page: Number(query.page || 1) || 1,
pageSize: Number(query.pageSize || 20) || 20,
status: query.status || 'pending_config',
})
}
/** @param {number | string} orderId */
export async function retryAdminNinetyoneOrder(orderId) {
const result = await retryOpen91Order(orderId)
return {
orderId: Number(result.order?.id || 0),
orderNo: String(result.order?.platform_order_id || '').trim(),
orderStatus: String(result.order?.order_status || '').trim(),
orderItemCount: Array.isArray(result.orderItems) ? result.orderItems.length : 0,
taskCount: Array.isArray(result.tasks) ? result.tasks.length : 0,
}
}
/** @param {number | string} orderId */
/** @param {{ reason?: string }} [payload] */
export async function failAdminNinetyoneOrder(orderId, payload = {}) {
const result = await failOpen91Order(orderId, payload.reason)
return {
orderId: Number(result.order?.id || 0),
orderNo: String(result.order?.platform_order_id || '').trim(),
orderStatus: String(result.order?.order_status || '').trim(),
orderItemCount: Array.isArray(result.orderItems) ? result.orderItems.length : 0,
taskCount: Array.isArray(result.tasks) ? result.tasks.length : 0,
}
}
@@ -11,6 +11,12 @@ export {
syncAdminKhhaoOrders,
} from './khhao-service.js'
export {
getAdminNinetyoneOrders,
retryAdminNinetyoneOrder,
failAdminNinetyoneOrder,
} from './ninetyone-service.js'
export {
getAdminKuaishouEticketSourceConfig,
updateAdminKuaishouEticketSourceConfig,
@@ -1,8 +1,10 @@
import { hasConfiguredOrderItems } from '../order/product-match-service.js'
import { upsertOrderFromSource } from '../order/order-service.js'
import { nowIso } from '../../utils/time.js'
import { logWebhook } from '../../utils/logger.js'
import { parseAmountToFen } from '../../utils/money.js'
import {
buildOpen91SourceEvent,
upsertOpen91PendingOrder,
} from '../platforms/ninetyone/order-service.js'
import {
assertOpen91Config,
assertOpen91CreatePayload,
@@ -14,8 +16,6 @@ import {
normalizeOpen91CreatePayload,
OPEN_91_COST_EXCEED_FAIL_CODE,
OPEN_91_DEFAULT_FAIL_CODE,
OPEN_91_PLATFORM,
OPEN_91_PROVIDER,
} from './shared.js'
export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
@@ -26,42 +26,6 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
assertOpen91Timestamp(normalized.timestamp, config.timestampToleranceSeconds)
assertOpen91Signature(payload, config)
const now = nowIso()
const items = [
{
externalItemId: normalized.productNo,
externalSkuCode: normalized.productNo,
externalSkuName: normalized.productNo,
quantity: normalized.buyNum,
spec: {
source: OPEN_91_PROVIDER,
},
snapshot: {
source: OPEN_91_PROVIDER,
orderNo: normalized.orderNo,
productNo: normalized.productNo,
callbackUrl: normalized.callbackUrl,
},
},
]
const configured = await hasConfiguredOrderItems({
provider: OPEN_91_PROVIDER,
platform: OPEN_91_PLATFORM,
shopId: config.shopId,
items,
})
if (!configured) {
return buildOpen91SuccessResponse({
orderNo: normalized.orderNo,
outTradeNo: '',
orderStatus: 30,
failCode: OPEN_91_DEFAULT_FAIL_CODE,
failReason: '商品未配置或未匹配到履约规则',
})
}
if (normalized.maxAmount) {
const maxAmountFen = parseAmountToFen(normalized.maxAmount)
if (maxAmountFen < 0) {
@@ -75,26 +39,8 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
}
}
const result = await upsertOrderFromSource({
provider: OPEN_91_PROVIDER,
platform: OPEN_91_PLATFORM,
shopId: config.shopId,
shopName: config.shopName,
platformOrderId: normalized.orderNo,
orderStatus: 'paid',
payStatus: 'paid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
rawPayload: {
source: OPEN_91_PROVIDER,
...normalized,
},
paidAt: now,
items,
}, {
const sourceEvent = buildOpen91SourceEvent(normalized, config)
const result = await upsertOrderFromSource(sourceEvent, {
sourceLabel: 'open91',
})
@@ -103,13 +49,31 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
orderNo: normalized.orderNo,
productNo: normalized.productNo,
buyNum: normalized.buyNum,
configured,
ignored: Boolean(result.ignored),
ignoreReason: result.ignoreReason || '',
orderId: result.order?.id || null,
taskCount: Array.isArray(result.tasks) ? result.tasks.length : 0,
})
if (result.ignored || !result.order || !Array.isArray(result.tasks) || result.tasks.length === 0) {
if (result.ignored) {
const pending = await upsertOpen91PendingOrder(normalized, config)
logWebhook('[open-91/create]', '91卡券订单已进入待补全队列', {
requestId,
orderNo: normalized.orderNo,
productNo: normalized.productNo,
orderId: pending.order?.id || null,
})
return buildOpen91SuccessResponse({
orderNo: normalized.orderNo,
outTradeNo: buildOpen91OutTradeNo(pending.order),
orderStatus: 10,
orderCost: buildOpen91OrderCost(0),
cards: '',
})
}
if (!result.order || !Array.isArray(result.tasks) || result.tasks.length === 0) {
return buildOpen91SuccessResponse({
orderNo: normalized.orderNo,
outTradeNo: '',
@@ -3,6 +3,10 @@ import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { buildClaimUrl } from '../claim/claim-service.js'
import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud-task-service.js'
import { logWebhook } from '../../utils/logger.js'
import {
OPEN_91_MANUAL_FAILED_STATUS,
OPEN_91_PENDING_CONFIG_STATUS,
} from '../platforms/ninetyone/order-service.js'
import {
assertOpen91Config,
assertOpen91QueryPayload,
@@ -46,6 +50,31 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) {
}
const tasks = await listTasksByOrderId(order.id)
if (tasks.length === 0) {
if (String(order.order_status || '').trim() === OPEN_91_PENDING_CONFIG_STATUS) {
return buildOpen91SuccessResponse({
orderNo: normalized.orderNo,
outTradeNo: buildOpen91OutTradeNo(order),
orderStatus: 10,
failCode: 0,
failReason: '',
orderCost: buildOpen91OrderCost(0),
cards: '',
})
}
if (String(order.order_status || '').trim() === OPEN_91_MANUAL_FAILED_STATUS) {
return buildOpen91SuccessResponse({
orderNo: normalized.orderNo,
outTradeNo: buildOpen91OutTradeNo(order),
orderStatus: 30,
failCode: OPEN_91_DEFAULT_FAIL_CODE,
failReason: resolveOpen91OrderFailReason(order) || '商家手动标记无法履约',
})
}
}
const cardItems = []
const readyTaskIds = []
@@ -123,3 +152,25 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) {
cards: buildOpen91Cards(cardItems, config),
})
}
function resolveOpen91OrderFailReason(order) {
const payload = parseJsonObject(order?.raw_payload_json)
return String(payload.manualFailedReason || '').trim()
}
function parseJsonObject(value) {
if (!value) {
return {}
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value
}
try {
const parsed = JSON.parse(String(value || '{}'))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
@@ -0,0 +1,346 @@
// @ts-check
import { query } from '../../../db/client.js'
import { createOrder, findOrderByPlatformOrderId, getOrderById, updateOrder } from '../../../repositories/order-repo.js'
import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js'
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
import { upsertOrderFromSource } from '../../order/order-service.js'
import {
buildOpen91OutTradeNo,
normalizeOpen91CreatePayload,
OPEN_91_PLATFORM,
OPEN_91_PROVIDER,
} from '../../open-91/shared.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
export const OPEN_91_PENDING_CONFIG_STATUS = 'pending_config'
export const OPEN_91_MANUAL_FAILED_STATUS = 'manual_failed'
export function buildOpen91SourceEvent(payload = {}, config = {}) {
const normalized = normalizeOpen91CreatePayload(payload)
const productNo = normalized.productNo
return {
provider: OPEN_91_PROVIDER,
platform: OPEN_91_PLATFORM,
shopId: String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER,
shopName: String(config.shopName || '91卡券').trim() || '91卡券',
platformOrderId: normalized.orderNo,
orderStatus: 'paid',
payStatus: 'paid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
paidAt: nowIso(),
rawPayload: {
source: OPEN_91_PROVIDER,
receivedAt: nowIso(),
body: normalized,
},
items: [
{
itemId: productNo,
externalItemId: productNo,
externalSkuCode: productNo,
externalSkuName: productNo,
skuCode: productNo,
skuName: productNo,
quantity: normalized.buyNum,
spec: {
source: OPEN_91_PROVIDER,
orderNo: normalized.orderNo,
productNo,
},
snapshot: {
source: OPEN_91_PROVIDER,
orderNo: normalized.orderNo,
productNo,
externalItemId: productNo,
externalSkuCode: productNo,
externalSkuName: productNo,
callbackUrl: normalized.callbackUrl,
},
},
],
}
}
export async function upsertOpen91PendingOrder(payload = {}, config = {}) {
const event = buildOpen91SourceEvent(payload, config)
const now = nowIso()
const existing = await findOrderByPlatformOrderId({
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
platformOrderId: event.platformOrderId,
})
const rawPayloadJson = JSON.stringify({
...event.rawPayload,
pendingReason: 'unconfigured_items',
pendingAt: now,
})
const orderPayload = {
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
shopName: event.shopName,
platformOrderId: event.platformOrderId,
orderStatus: OPEN_91_PENDING_CONFIG_STATUS,
payStatus: 'paid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
rawPayloadJson,
paidAt: existing?.paid_at || now,
}
const order = existing
? await updateOrder(existing.id, { ...orderPayload, updatedAt: now })
: await createOrder({ ...orderPayload, createdAt: now, updatedAt: now })
if (!order) {
throw createHttpError('91卡券待处理订单保存失败', {
statusCode: 500,
errorCode: 'open91_pending_order_save_failed',
})
}
const [item] = event.items
const orderItems = await replaceOrderItems(order.id, [
{
skuCode: item.skuCode,
skuName: item.skuName,
quantity: item.quantity,
specJson: JSON.stringify(item.spec || {}),
itemSnapshotJson: JSON.stringify(item.snapshot || item.spec || {}),
createdAt: now,
updatedAt: now,
},
])
return {
order,
orderItems,
tasks: [],
}
}
export async function retryOpen91Order(orderId) {
const order = await getRequiredOpen91Order(orderId)
const items = await listOrderItemsByOrderId(order.id)
const event = buildOpen91SourceEventFromOrder(order, items)
const result = await upsertOrderFromSource(event, { sourceLabel: 'open91-admin-retry' })
if (result.ignored) {
throw createHttpError('当前 91 订单仍未命中履约配置,请先补全商品规则', {
statusCode: 409,
errorCode: 'open91_order_still_unconfigured',
context: {
ignoreReason: result.ignoreReason || 'unconfigured_items',
},
})
}
return {
order: result.order,
orderItems: result.orderItems || [],
tasks: result.tasks || [],
}
}
export async function failOpen91Order(orderId, reason = '') {
const order = await getRequiredOpen91Order(orderId)
const now = nowIso()
const rawPayload = parseJsonObject(order.raw_payload_json)
const updated = await updateOrder(order.id, {
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
shopName: order.shop_name,
platformOrderId: order.platform_order_id,
orderStatus: OPEN_91_MANUAL_FAILED_STATUS,
payStatus: order.pay_status,
buyerId: order.buyer_id,
buyerName: order.buyer_name,
receiverContact: order.receiver_contact,
totalAmount: order.total_amount,
currency: order.currency,
rawPayloadJson: JSON.stringify({
...rawPayload,
manualFailedAt: now,
manualFailedReason: String(reason || '').trim() || '商家手动标记无法履约',
}),
paidAt: order.paid_at,
updatedAt: now,
})
return {
order: updated,
orderItems: await listOrderItemsByOrderId(order.id),
tasks: await listTasksByOrderId(order.id),
}
}
export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pending_config' } = {}) {
const normalizedPage = Math.max(1, Number(page) || 1)
const normalizedPageSize = Math.min(100, Math.max(1, Number(pageSize) || 20))
const normalizedStatus = String(status || 'pending_config').trim()
const filters = [
'o.provider = $1',
'o.platform = $2',
]
/** @type {Array<string | number>} */
const params = [OPEN_91_PROVIDER, OPEN_91_PLATFORM]
if (normalizedStatus && normalizedStatus !== 'all') {
params.push(normalizedStatus)
filters.push(`o.order_status = $${params.length}`)
}
const whereClause = `WHERE ${filters.join(' AND ')}`
const totalResult = await query(`SELECT COUNT(*)::int AS total FROM orders o ${whereClause}`, params)
params.push(normalizedPageSize)
params.push((normalizedPage - 1) * normalizedPageSize)
const rowsResult = await query(
`
SELECT
o.*,
COALESCE(item_summary.items, '[]'::jsonb) AS items_json,
COALESCE(task_summary.task_count, 0)::int AS task_count
FROM orders o
LEFT JOIN LATERAL (
SELECT jsonb_agg(jsonb_build_object(
'orderItemId', oi.id,
'skuCode', oi.sku_code,
'skuName', oi.sku_name,
'quantity', oi.quantity,
'snapshot', oi.item_snapshot_json
) ORDER BY oi.id ASC) AS items
FROM order_items oi
WHERE oi.order_id = o.id
) item_summary ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS task_count
FROM fulfillment_tasks ft
WHERE ft.order_id = o.id
) task_summary ON TRUE
${whereClause}
ORDER BY o.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
)
return {
page: normalizedPage,
pageSize: normalizedPageSize,
total: Number(totalResult.rows[0]?.total || 0),
items: rowsResult.rows.map((row) => mapOpen91AdminOrderRow(row)),
}
}
export async function getRequiredOpen91Order(orderId) {
const order = await getOrderById(Number(orderId))
if (!order || order.provider !== OPEN_91_PROVIDER || order.platform !== OPEN_91_PLATFORM) {
throw createHttpError('91卡券订单不存在', {
statusCode: 404,
errorCode: 'open91_order_not_found',
})
}
return order
}
function buildOpen91SourceEventFromOrder(order, items = []) {
const rawPayload = parseJsonObject(order.raw_payload_json)
const body = parseJsonObject(rawPayload.body)
const normalizedItems = Array.isArray(items) ? items : []
const eventItems = normalizedItems.map((item) => {
const snapshot = parseJsonObject(item.item_snapshot_json)
const productNo = String(snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '').trim()
return {
itemId: productNo,
externalItemId: String(snapshot.externalItemId || productNo).trim(),
externalSkuCode: String(snapshot.externalSkuCode || productNo).trim(),
externalSkuName: String(snapshot.externalSkuName || item.sku_name || productNo).trim(),
skuCode: productNo,
skuName: String(item.sku_name || productNo).trim(),
quantity: Math.max(1, Number(item.quantity || body.buyNum || 1) || 1),
spec: parseJsonObject(item.spec_json),
snapshot,
}
})
return {
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
shopName: order.shop_name,
platformOrderId: order.platform_order_id,
orderStatus: 'paid',
payStatus: 'paid',
buyerId: order.buyer_id || '',
buyerName: order.buyer_name || '',
receiverContact: order.receiver_contact || '',
totalAmount: Number(order.total_amount || 0) || 0,
currency: order.currency || 'CNY',
paidAt: order.paid_at || nowIso(),
rawPayload: {
...rawPayload,
retriedAt: nowIso(),
},
items: eventItems,
}
}
function mapOpen91AdminOrderRow(row) {
const rawPayload = parseJsonObject(row.raw_payload_json)
const items = Array.isArray(row.items_json) ? row.items_json : []
const firstItem = items[0] || {}
const firstSnapshot = parseJsonObject(firstItem.snapshot)
const failReason = String(rawPayload.manualFailedReason || '').trim()
return {
orderId: Number(row.id || 0),
orderNo: String(row.platform_order_id || '').trim(),
outTradeNo: buildOpen91OutTradeNo(row),
orderStatus: String(row.order_status || '').trim(),
payStatus: String(row.pay_status || '').trim(),
shopId: String(row.shop_id || '').trim(),
shopName: String(row.shop_name || '').trim(),
productNo: String(firstSnapshot.productNo || firstSnapshot.externalSkuCode || firstItem.skuCode || '').trim(),
productName: String(firstSnapshot.externalSkuName || firstItem.skuName || '').trim(),
buyNum: Number(firstItem.quantity || 0),
taskCount: Number(row.task_count || 0),
failReason,
createdAt: row.created_at,
updatedAt: row.updated_at,
items,
}
}
function parseJsonObject(value) {
if (!value) {
return {}
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value
}
try {
const parsed = JSON.parse(String(value || '{}'))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
@@ -0,0 +1,31 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildOpen91SourceEvent } from './order-service.js'
test('buildOpen91SourceEvent maps 91 productNo into source item identities', () => {
const event = buildOpen91SourceEvent({
orderNo: 'P91KS202605130001',
productNo: 'KS-CLOUD-SKU-001',
buyNum: 2,
maxAmount: '0.01',
timestamp: 1778670308,
version: '1.0',
sign: 'SIGN',
}, {
shopId: '91kaquan',
shopName: '91卡券',
})
assert.equal(event.provider, '91kaquan')
assert.equal(event.platform, 'kuaishou')
assert.equal(event.shopId, '91kaquan')
assert.equal(event.platformOrderId, 'P91KS202605130001')
assert.equal(event.payStatus, 'paid')
assert.equal(event.items.length, 1)
assert.equal(event.items[0].externalItemId, 'KS-CLOUD-SKU-001')
assert.equal(event.items[0].externalSkuCode, 'KS-CLOUD-SKU-001')
assert.equal(event.items[0].externalSkuName, 'KS-CLOUD-SKU-001')
assert.equal(event.items[0].quantity, 2)
assert.equal(event.items[0].snapshot.productNo, 'KS-CLOUD-SKU-001')
})