补全一些基础信息
This commit is contained in:
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user