快手发码数据接入发单平台工单侧

- 新增 sync-work-orders-from-send-code 服务:91 先到则回填 shopId/补建工单/凭证回填 material_json;91 未到则兜底建单建工单
- order-service 与 ninetyone order-service 改用 findLatestOrderByPlatformOrderId,放宽 shopId 查重避免重复建单
- work-order-repo 新增 listWorkOrdersByPlatformOrderId 供兜底查重
- send-code-service 挂接同步逻辑并吞错,不影响发码主流程
- ProductRulesPanel 新增店铺列
- .gitignore 忽略 send_code/ 与 .reasonix/
This commit is contained in:
yml2213
2026-08-05 11:22:24 +08:00
parent 1dd92ec10c
commit 4bd2c174c5
7 changed files with 391 additions and 11 deletions
+2
View File
@@ -28,3 +28,5 @@ apps/backend/data/logs
.ace-tool
.claude
.snow
send_code/
.reasonix/
@@ -568,6 +568,18 @@ export async function listPendingMaterialWorkOrdersByPlatformOrderId(
return result.rows
}
export async function listWorkOrdersByPlatformOrderId(
platformOrderId: string,
): Promise<WorkOrderRow[]> {
const result = await query<WorkOrderRow>(
`${WORK_ORDER_SELECT}
WHERE wo.platform_order_id = $1
ORDER BY wo.id ASC`,
[String(platformOrderId || '').trim()],
)
return result.rows
}
export async function listWorkOrders({
page = 1,
pageSize = 20,
@@ -1,6 +1,6 @@
import {
createOrder,
findOrderByPlatformOrderId,
findLatestOrderByPlatformOrderId,
updateOrder,
} from '../../repositories/order-repo.js'
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
@@ -94,10 +94,9 @@ export async function upsertOrderFromSource(
{ sourceLabel = 'source' }: UpsertOrderSourceOptions = {},
): Promise<UpsertOrderResult> {
const now = nowIso()
const existing = await findOrderByPlatformOrderId({
const existing = await findLatestOrderByPlatformOrderId({
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
platformOrderId: event.platformOrderId,
})
@@ -146,8 +145,8 @@ export async function upsertOrderFromSource(
const basePayload = {
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
shopName: event.shopName,
shopId: String(existing?.shop_id || event.shopId || '').trim(),
shopName: String(existing?.shop_name || event.shopName || '').trim(),
platformOrderId: event.platformOrderId,
orderStatus: event.orderStatus,
payStatus: event.payStatus,
@@ -10,8 +10,9 @@ import {
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
import { retryOpen91Order } from '../ninetyone/order-service.js'
import { syncWorkOrdersFromKuaishouSendCode } from '../../worker-platform/sync-work-orders-from-send-code.js'
import { normalizeTimestampIso } from '../../../utils/time.js'
import { logIntegration } from '../../../utils/logger.js'
import { logWarn, logIntegration } from '../../../utils/logger.js'
import {
getKuaishouIndustryConfig,
assertMatchingAppKey,
@@ -119,6 +120,24 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
})
}
await syncWorkOrdersFromKuaishouSendCode({
oid: normalizedOid,
sellerId: params.sellerId,
itemId: params.itemId,
itemTitle: params.itemTitle,
skuId: params.skuId,
num: params.num,
paymentFen: resolveSendCodePaymentFen(params.ext),
rawParams: params,
vouchers,
now,
}).catch((error) => {
logWarn('[kuaishou-industry/send-code]', '发单平台工单同步失败,不影响发码主流程', {
oid: normalizedOid,
error: error instanceof Error ? error.message : String(error),
})
})
scheduleKuaishouIndustrySendCallbackRetry(normalizedOid, SEND_CALLBACK_INITIAL_DELAY_MS, {
source: 'send_code_accepted',
})
@@ -671,6 +690,17 @@ function normalizePositiveInteger(value: unknown): number {
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 0
}
function resolveSendCodePaymentFen(ext: unknown): number {
const parsed = parseJsonObject(ext)
return normalizePositiveInteger(
parsed.payment
?? parsed.totalGoodsValue
?? parsed.goodsValue
?? parsed.payAmount
?? parsed.amount,
)
}
function parseJsonObject(value: unknown): JsonObject {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as JsonObject
@@ -1,7 +1,7 @@
import { query } from '../../../db/client.js'
import {
createOrder,
findOrderByPlatformOrderId,
findLatestOrderByPlatformOrderId,
getOrderById,
updateOrder,
} from '../../../repositories/order-repo.js'
@@ -122,10 +122,9 @@ export function parseOpen91ProductNo(value: unknown) {
export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config: JsonObject = {}) {
const event = buildOpen91SourceEvent(payload, config)
const now = nowIso()
const existing = await findOrderByPlatformOrderId({
const existing = await findLatestOrderByPlatformOrderId({
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
platformOrderId: event.platformOrderId,
})
@@ -137,8 +136,8 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config:
const orderPayload = {
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
shopName: event.shopName,
shopId: String(existing?.shop_id || event.shopId || '').trim(),
shopName: String(existing?.shop_name || event.shopName || '').trim(),
platformOrderId: event.platformOrderId,
orderStatus: OPEN_91_PENDING_CONFIG_STATUS,
payStatus: 'paid',
@@ -0,0 +1,326 @@
/**
* 快手 send-code 数据接入发单平台(工单侧)。
*
* 定位:91 回调是订单骨架权威源,快手 send-code 是数据补全源与兜底源。
* - 订单已存在(91 先到):回填 shop_id91 的 productNo 无店铺后缀时)、
* 补建缺失工单、把凭证信息回填已建工单 material_json
* - 订单不存在(91 未到/缺失):用快手数据兜底建单 + 建工单 + 凭证回填
*
* 幂等依据:orders 唯一键 provider+platform+shop_id+platform_order_id
* (由 91 侧查重逻辑保证同 oid 单条记录)、work_orders.order_item_id 唯一索引、
* listWorkOrdersByPlatformOrderId 查重。
*
* 本服务只写 orders / order_items / work_orders,不触碰
* upsertOrderFromSource / syncDeliveryTasksForOrder / readiness / 回调链路。
*/
import {
createOrder,
findLatestOrderByPlatformOrderId,
updateOrder,
} from '../../repositories/order-repo.js'
import { listOrderItemsByOrderId, replaceOrderItems } from '../../repositories/order-item-repo.js'
import {
listWorkOrdersByPlatformOrderId,
updateWorkOrder,
} from '../../repositories/worker-platform/work-order-repo.js'
import { syncWorkerOrdersForSourceOrder } from './admin-service.js'
import { createWorkOrderEvent } from '../../repositories/worker-platform/work-order-repo.js'
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../open-91/config.js'
import { asJsonObject, type JsonObject } from '../../types/json.js'
import type { OrderUpdateInput } from '../../types/repository/inputs.js'
import type {
KuaishouIndustryVoucherRow,
OrderRow,
} from '../../types/repository/rows.js'
const KUAISHOU_SEND_CODE_SOURCE = 'kuaishou_send_code'
const KUAISHOU_SEND_CODE_ORDER_STATUS = 'paid'
export type SyncWorkOrdersFromSendCodeInput = {
oid: string
sellerId: string
itemId: string
itemTitle: string
skuId: string
num: number
paymentFen: number
rawParams: JsonObject
vouchers: KuaishouIndustryVoucherRow[]
now: string
}
export type SyncWorkOrdersFromSendCodeResult = {
order: OrderRow | null
orderId: number | null
workOrderIds: number[]
createdWorkOrderCount: number
backfilledVoucherCount: number
skipped: string[]
}
export async function syncWorkOrdersFromKuaishouSendCode(
input: SyncWorkOrdersFromSendCodeInput,
): Promise<SyncWorkOrdersFromSendCodeResult> {
const normalizedOid = String(input.oid || '').trim()
const skipped: string[] = []
if (!normalizedOid) {
return {
order: null,
orderId: null,
workOrderIds: [],
createdWorkOrderCount: 0,
backfilledVoucherCount: 0,
skipped: ['missing_oid'],
}
}
let order = await findLatestOrderByPlatformOrderId({
provider: OPEN_91_PROVIDER,
platform: OPEN_91_PLATFORM,
platformOrderId: normalizedOid,
})
if (order) {
const orderResult = await backfillOrderShopId(order, input)
order = orderResult.order
if (orderResult.skipped) {
skipped.push(orderResult.skipped)
}
} else {
const created = await createFallbackOrder(input)
if (created.order) {
order = created.order
} else {
skipped.push(created.reason || 'order_create_failed')
}
}
const workOrderIds: number[] = []
let createdWorkOrderCount = 0
if (order) {
const orderItems = await listOrderItemsByOrderId(order.id)
const syncResult = await syncWorkerOrdersForSourceOrder(order, orderItems, {
source: KUAISHOU_SEND_CODE_SOURCE,
autoOnly: true,
})
createdWorkOrderCount = syncResult.createdCount
for (const item of syncResult.skipped) {
skipped.push(`work_order:${item.reason}`)
}
}
const backfilled = await backfillVouchersToWorkOrders(normalizedOid, input.vouchers, input.now)
for (const workOrderId of backfilled.workOrderIds) {
workOrderIds.push(workOrderId)
}
if (backfilled.skipped) {
skipped.push(backfilled.skipped)
}
return {
order,
orderId: order?.id ?? null,
workOrderIds,
createdWorkOrderCount,
backfilledVoucherCount: backfilled.count,
skipped,
}
}
async function backfillOrderShopId(
order: OrderRow,
input: SyncWorkOrdersFromSendCodeInput,
): Promise<{ order: OrderRow; skipped?: string }> {
const sellerId = String(input.sellerId || '').trim()
const currentShopId = String(order.shop_id || '').trim()
if (!sellerId || currentShopId === sellerId) {
return { order }
}
const isPlaceholderShopId = !currentShopId || currentShopId === OPEN_91_PROVIDER
if (!isPlaceholderShopId) {
return { order, skipped: `shop_id_conflict:${currentShopId}` }
}
const updated = await updateOrder(order.id, {
...buildOrderUpdateBase(order),
shopId: sellerId,
updatedAt: input.now,
})
return {
order: updated || order,
}
}
function buildOrderUpdateBase(order: OrderRow): Omit<OrderUpdateInput, 'updatedAt'> {
return {
provider: String(order.provider || ''),
platform: String(order.platform || ''),
shopId: String(order.shop_id || ''),
shopName: String(order.shop_name || ''),
platformOrderId: String(order.platform_order_id || ''),
orderStatus: String(order.order_status || ''),
payStatus: String(order.pay_status || ''),
buyerId: String(order.buyer_id || ''),
buyerName: String(order.buyer_name || ''),
receiverContact: String(order.receiver_contact || ''),
totalAmount: Number(order.total_amount) || 0,
currency: String(order.currency || 'CNY'),
rawPayloadJson: order.raw_payload_json || '{}',
paidAt: order.paid_at || null,
}
}
async function createFallbackOrder(input: SyncWorkOrdersFromSendCodeInput): Promise<{
order: OrderRow | null
reason?: string
}> {
const now = input.now
const sellerId = String(input.sellerId || '').trim()
const itemId = String(input.itemId || '').trim()
const itemTitle = String(input.itemTitle || '').trim()
const num = Math.max(1, Number(input.num) || 1)
const paymentFen = Math.max(0, Number(input.paymentFen) || 0)
if (!itemId && !itemTitle) {
return { order: null, reason: 'order_item_missing' }
}
const order = await createOrder({
provider: OPEN_91_PROVIDER,
platform: OPEN_91_PLATFORM,
shopId: sellerId,
shopName: sellerId ? `快手店铺-${sellerId}` : '',
platformOrderId: input.oid,
orderStatus: KUAISHOU_SEND_CODE_ORDER_STATUS,
payStatus: 'paid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: paymentFen,
currency: 'CNY',
rawPayloadJson: {
source: KUAISHOU_SEND_CODE_SOURCE,
receivedAt: now,
body: input.rawParams,
},
paidAt: now,
createdAt: now,
updatedAt: now,
})
if (!order) {
return { order: null, reason: 'order_create_failed' }
}
await replaceOrderItems(order.id, [
{
skuCode: itemId,
skuName: itemTitle,
quantity: num,
specJson: JSON.stringify({
sellerId,
skuId: String(input.skuId || '').trim(),
paymentFen,
source: KUAISHOU_SEND_CODE_SOURCE,
}),
itemSnapshotJson: JSON.stringify({
source: KUAISHOU_SEND_CODE_SOURCE,
params: input.rawParams,
}),
createdAt: now,
updatedAt: now,
},
])
return { order }
}
async function backfillVouchersToWorkOrders(
oid: string,
vouchers: KuaishouIndustryVoucherRow[],
now: string,
): Promise<{ workOrderIds: number[]; count: number; skipped?: string }> {
const workOrders = await listWorkOrdersByPlatformOrderId(oid)
if (workOrders.length === 0) {
return { workOrderIds: [], count: 0, skipped: 'work_order_not_found' }
}
const voucherItems = vouchers.map((voucher) => ({
voucherCode: String(voucher.voucher_code || ''),
token: String(voucher.token || ''),
eticketType: String(voucher.eticket_type || ''),
status: String(voucher.status || ''),
validStartTime: Number(voucher.valid_start_time) || 0,
validEndTime: Number(voucher.valid_end_time) || 0,
}))
const workOrderIds: number[] = []
let count = 0
for (const workOrder of workOrders) {
const material = asJsonObject(safeParseJson(workOrder.material_json))
const existing = Array.isArray(material.kuaishouIndustryVoucher)
? (material.kuaishouIndustryVoucher as unknown[])
: []
const existingCodes = new Set(
existing.map((item) =>
String((item as JsonObject)?.voucherCode || (item as JsonObject)?.code || ''),
),
)
const merged = [
...existing,
...voucherItems.filter((item) => !existingCodes.has(item.voucherCode)),
]
material.kuaishouIndustryVoucher = merged
const updated = await updateWorkOrder(workOrder.id, {
material_json: JSON.stringify(material),
updated_at: now,
})
if (!updated) {
continue
}
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'system',
actorId: KUAISHOU_SEND_CODE_SOURCE,
eventType: 'voucher_backfilled',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({
oid,
voucherCount: voucherItems.length,
}),
now,
})
workOrderIds.push(Number(workOrder.id))
count += 1
}
return { workOrderIds, count }
}
function safeParseJson(value: unknown): JsonObject {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as JsonObject
}
try {
const parsed = JSON.parse(String(value || '{}'))
return asJsonObject(parsed)
} catch {
return {}
}
}
@@ -173,6 +173,18 @@ export default function ProductRulesPanel() {
</div>
),
},
{
title: '店铺',
width: 120,
render: (_, row) => {
const shopId = String(row.shopId || '')
return shopId ? (
<Tag color="geekblue">{shopId}</Tag>
) : (
<Tag></Tag>
)
},
},
{
title: 'SKU',
dataIndex: 'skuCode',