From 4bd2c174c5c6ec349e18562faa9bcb57773d008b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 5 Aug 2026 11:22:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BF=AB=E6=89=8B=E5=8F=91=E7=A0=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=8E=A5=E5=85=A5=E5=8F=91=E5=8D=95=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E4=BE=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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/ --- .gitignore | 2 + .../worker-platform/work-order-repo.ts | 12 + .../src/services/order/order-service.ts | 9 +- .../kuaishou-industry/send-code-service.ts | 32 +- .../platforms/ninetyone/order-service.ts | 9 +- .../sync-work-orders-from-send-code.ts | 326 ++++++++++++++++++ .../pages/admin/panels/ProductRulesPanel.tsx | 12 + 7 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts diff --git a/.gitignore b/.gitignore index 106dd24d..e59e220d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ apps/backend/data/logs .ace-tool .claude .snow +send_code/ +.reasonix/ diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index e9f0dbcb..9f9d14f1 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -568,6 +568,18 @@ export async function listPendingMaterialWorkOrdersByPlatformOrderId( return result.rows } +export async function listWorkOrdersByPlatformOrderId( + platformOrderId: string, +): Promise { + const result = await query( + `${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, diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index 8e361684..3ac295fb 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -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 { 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, diff --git a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts index ce7f7c06..07b3e649 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts @@ -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 diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index f691a2fb..ad9f9693 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -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', diff --git a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts new file mode 100644 index 00000000..1f7ad267 --- /dev/null +++ b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts @@ -0,0 +1,326 @@ +/** + * 快手 send-code 数据接入发单平台(工单侧)。 + * + * 定位:91 回调是订单骨架权威源,快手 send-code 是数据补全源与兜底源。 + * - 订单已存在(91 先到):回填 shop_id(91 的 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 { + 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 { + 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 {} + } +} diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index 45c27835..78985c25 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -173,6 +173,18 @@ export default function ProductRulesPanel() { ), }, + { + title: '店铺', + width: 120, + render: (_, row) => { + const shopId = String(row.shopId || '') + return shopId ? ( + {shopId} + ) : ( + 通配 + ) + }, + }, { title: 'SKU', dataIndex: 'skuCode',