diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts index 36643f97..f7ad42c9 100644 --- a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -201,6 +201,27 @@ export async function listKuaishouIndustryVouchersByOid( return result.rows } +export async function listKuaishouIndustryVouchersByOids( + oids: string[], +): Promise { + const normalizedOids = oids.map((oid) => String(oid || '').trim()).filter(Boolean) + if (normalizedOids.length === 0) { + return [] + } + + const result = await query( + ` + SELECT * + FROM kuaishou_industry_vouchers + WHERE oid = ANY($1::text[]) + ORDER BY unit_index ASC, id ASC + `, + [normalizedOids], + ) + + return result.rows +} + export type KuaishouIndustryVoucherWorkOrderSyncSource = { oid: string raw_payload_json: string | Record diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index c919398f..3834fab1 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -235,16 +235,27 @@ export async function getOrderItemById(orderItemId: number | string): Promise { + const specClause = input.specJson ? ', spec_json = $5::jsonb' : '' + const params: unknown[] = [ + input.skuName, + input.itemSnapshotJson, + input.updatedAt, + Number(orderItemId), + ] + if (input.specJson) { + params.push(input.specJson) + } + const result = await query<{ id: number }>( ` UPDATE order_items - SET sku_name = $1, item_snapshot_json = $2::jsonb, updated_at = $3 + SET sku_name = $1, item_snapshot_json = $2::jsonb, updated_at = $3 ${specClause} WHERE id = $4 RETURNING id `, - [input.skuName, input.itemSnapshotJson, input.updatedAt, Number(orderItemId)], + params, ) return result.rows[0] ? getOrderItemById(orderItemId) : null } diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index 944d14e2..a112fd71 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -243,7 +243,8 @@ export async function listOrdersWithExecutor( filters.push(`( ( o.platform_order_id || ' ' || o.shop_id || ' ' || o.shop_name || ' ' || - o.buyer_id || ' ' || o.buyer_name || ' ' || o.receiver_contact + o.buyer_id || ' ' || o.buyer_name || ' ' || o.receiver_contact || ' ' || + o.raw_payload_json::text ) ILIKE ${keywordParam} OR EXISTS ( SELECT 1 @@ -252,7 +253,8 @@ export async function listOrdersWithExecutor( AND ( oi.sku_code || ' ' || oi.sku_name || ' ' || COALESCE(oi.item_snapshot_json->'kuaishouSendCode'->>'itemTitle', '') || ' ' || - COALESCE(oi.item_snapshot_json->'kuaishouSendCode'->>'skuNick', '') + COALESCE(oi.item_snapshot_json->'kuaishouSendCode'->>'skuNick', '') || ' ' || + COALESCE(oi.spec_json::text, '') ) ILIKE ${keywordParam} ) )`) diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.test.ts b/apps/backend/src/services/admin/admin-order-read-helpers.test.ts new file mode 100644 index 00000000..a9c2c511 --- /dev/null +++ b/apps/backend/src/services/admin/admin-order-read-helpers.test.ts @@ -0,0 +1,126 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js' +import type { OrderItemRow, OrderListRow } from '../../types/repository/rows.js' + +test('mapAdminOrderListItem formats itemTitle, skuNick, and summary correctly for single item', () => { + const order: OrderListRow = { + id: 1, + provider: '91', + platform: 'kuaishou', + shop_id: '4269276762', + shop_name: '独角兽游戏专营店', + platform_order_id: '2625301761098133', + order_status: 'paid', + pay_status: 'paid', + buyer_id: '', + buyer_name: '', + receiver_contact: '', + total_amount: 16900, + currency: 'CNY', + raw_payload_json: {}, + paid_at: '2026-09-10T09:15:00.000Z', + created_at: '2026-09-10T09:15:00.000Z', + updated_at: '2026-09-10T09:15:00.000Z', + } + + const orderItems: OrderItemRow[] = [ + { + id: 10, + order_id: 1, + sku_code: '26079757920762', + sku_name: '安卓血宴2钻', + quantity: 1, + spec_json: JSON.stringify({ + itemTitle: '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻', + skuNick: '安卓血宴2钻', + }), + item_snapshot_json: JSON.stringify({ + kuaishouSendCode: { + itemTitle: '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻', + skuNick: '安卓血宴2钻', + }, + }), + }, + ] + + const mapped = mapAdminOrderListItem(order, { orderItems, tasks: [] }) + + assert.equal(mapped.itemTitle, '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻') + assert.equal(mapped.skuNick, '安卓血宴2钻') + assert.equal( + mapped.itemSummary, + '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻 (安卓血宴2钻)', + ) + assert.equal(mapped.itemCount, 1) + assert.equal(mapped.totalQuantity, 1) + assert.deepEqual(mapped.items, [ + { + itemTitle: '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻', + skuNick: '安卓血宴2钻', + quantity: 1, + }, + ]) +}) + +test('mapAdminOrderListItem falls back to order raw_payload_json when orderItems is empty', () => { + const order: OrderListRow = { + id: 2, + provider: '91', + platform: 'kuaishou', + shop_id: '4269276762', + shop_name: '独角兽游戏专营店', + platform_order_id: '2625301841249202', + order_status: 'paid', + pay_status: 'paid', + buyer_id: '', + buyer_name: '', + receiver_contact: '', + total_amount: 5200, + currency: 'CNY', + raw_payload_json: { + source: 'kuaishou_send_code', + body: { + itemTitle: '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪', + num: 1, + ext: '{"skuNick":"安卓血宴1钻"}', + }, + }, + paid_at: '2026-09-10T09:18:40.000Z', + created_at: '2026-09-10T09:18:40.000Z', + updated_at: '2026-09-10T09:18:40.000Z', + } + + const mapped = mapAdminOrderListItem(order, { orderItems: [], tasks: [] }) + + assert.equal(mapped.itemTitle, '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪') + assert.equal(mapped.skuNick, '安卓血宴1钻') + assert.equal(mapped.itemCount, 1) + assert.equal(mapped.totalQuantity, 1) + assert.equal(mapped.itemSummary, '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪 (安卓血宴1钻)') +}) + +test('summarizeOrderItems formats multiple items cleanly', () => { + const items: OrderItemRow[] = [ + { + id: 1, + order_id: 1, + sku_code: 'SKU1', + sku_name: '规格A', + quantity: 1, + spec_json: { itemTitle: '商品1', skuNick: '规格A' }, + }, + { + id: 2, + order_id: 1, + sku_code: 'SKU2', + sku_name: '规格B', + quantity: 1, + spec_json: { itemTitle: '商品2', skuNick: '规格B' }, + }, + ] + + const summary = summarizeOrderItems(items) + assert.equal(summary, '商品1 (规格A) 等 2 项') +}) diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index a06af282..3a2c861d 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -1,16 +1,71 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js' -import { resolveDisplayShopName, resolveOrderItemTitle } from './admin-read-shared-helpers.js' +import { + resolveDisplayShopName, + resolveOrderItemSkuNick, + resolveOrderItemTitle, +} from './admin-read-shared-helpers.js' import { buildOrderFulfillmentProgress } from './admin-task-read-helpers.js' -import type { AdminOrderListItem } from '../../types/admin/read-models.js' -import type { OrderItemRow, OrderListRow, TaskRow } from '../../types/repository/rows.js' +import type { AdminOrderItemSummary, AdminOrderListItem } from '../../types/admin/read-models.js' +import type { + KuaishouIndustryVoucherRow, + OrderItemRow, + OrderListRow, + TaskRow, +} from '../../types/repository/rows.js' export function mapAdminOrderListItem( item: OrderListRow, - { tasks = [], orderItems = [] }: { tasks?: TaskRow[]; orderItems?: OrderItemRow[] } = {}, + { + tasks = [], + orderItems = [], + vouchers = [], + }: { + tasks?: TaskRow[] + orderItems?: OrderItemRow[] + vouchers?: KuaishouIndustryVoucherRow[] + } = {}, ): AdminOrderListItem { const fulfillmentProgress = buildOrderFulfillmentProgress(tasks) - const itemSummary = summarizeOrderItems(orderItems) + const normalizedItems = Array.isArray(orderItems) ? orderItems : [] + const itemSummary = summarizeOrderItems(normalizedItems, item, vouchers) + + let resolvedItemTitle = '' + let resolvedSkuNick = '' + let itemsList: AdminOrderItemSummary[] = [] + + if (normalizedItems.length > 0) { + itemsList = normalizedItems.map((orderItem) => { + const itTitle = resolveOrderItemTitle(orderItem, item, vouchers) + const itSkuNick = resolveOrderItemSkuNick(orderItem, item, vouchers) + return { + itemTitle: itTitle, + skuNick: itSkuNick, + quantity: Math.max(1, Number(orderItem.quantity || 1)), + } + }) + resolvedItemTitle = itemsList[0]?.itemTitle || '' + resolvedSkuNick = itemsList[0]?.skuNick || '' + } else { + resolvedItemTitle = resolveOrderItemTitle(null, item, vouchers) + resolvedSkuNick = resolveOrderItemSkuNick(null, item, vouchers) + if (resolvedItemTitle || resolvedSkuNick) { + itemsList = [ + { + itemTitle: resolvedItemTitle, + skuNick: resolvedSkuNick, + quantity: 1, + }, + ] + } + } + + const rawItemCount = normalizedItems.length + const rawTotalQuantity = normalizedItems.reduce( + (sum, orderItem) => sum + Math.max(1, Number(orderItem.quantity || 1)), + 0, + ) + const hasResolvedFallback = (resolvedItemTitle || resolvedSkuNick) && rawItemCount === 0 return { orderId: item.id, @@ -30,12 +85,12 @@ export function mapAdminOrderListItem( paidAt: item.paid_at, createdAt: item.created_at, updatedAt: item.updated_at, - itemCount: orderItems.length, - totalQuantity: orderItems.reduce( - (sum, orderItem) => sum + Math.max(1, Number(orderItem.quantity || 1)), - 0, - ), + itemCount: hasResolvedFallback ? 1 : rawItemCount, + totalQuantity: hasResolvedFallback ? 1 : rawTotalQuantity, itemSummary, + itemTitle: resolvedItemTitle, + skuNick: resolvedSkuNick, + items: itemsList, taskCount: tasks.length, resourceStatus: fulfillmentProgress.resourceStatus, customerStatus: fulfillmentProgress.customerStatus, @@ -44,17 +99,28 @@ export function mapAdminOrderListItem( } } -export function summarizeOrderItems(items: OrderItemRow[] | null | undefined): string { +export function summarizeOrderItems( + items: OrderItemRow[] | null | undefined, + fallbackOrder?: OrderListRow | null | undefined, + vouchers?: KuaishouIndustryVoucherRow[] | null | undefined, +): string { const normalizedItems = Array.isArray(items) ? items : [] if (normalizedItems.length === 0) { - return '' + const fallbackTitle = resolveOrderItemTitle(null, fallbackOrder, vouchers) + const fallbackSkuNick = resolveOrderItemSkuNick(null, fallbackOrder, vouchers) + if (!fallbackTitle && !fallbackSkuNick) { + return '' + } + return formatItemTitleWithSku(fallbackTitle, fallbackSkuNick) } const [firstItem] = normalizedItems - const firstLabel = - resolveOrderItemTitle(firstItem) || + const firstTitle = + resolveOrderItemTitle(firstItem, fallbackOrder, vouchers) || String(firstItem?.sku_name || firstItem?.sku_code || '').trim() + const firstSkuNick = resolveOrderItemSkuNick(firstItem, fallbackOrder, vouchers) + const firstLabel = formatItemTitleWithSku(firstTitle, firstSkuNick) if (normalizedItems.length === 1) { return firstLabel @@ -62,3 +128,14 @@ export function summarizeOrderItems(items: OrderItemRow[] | null | undefined): s return `${firstLabel} 等 ${normalizedItems.length} 项` } + +function formatItemTitleWithSku(title: string, skuNick: string): string { + const cleanTitle = String(title || '').trim() + const cleanSku = String(skuNick || '').trim() + + if (cleanTitle && cleanSku && !cleanTitle.includes(cleanSku)) { + return `${cleanTitle} (${cleanSku})` + } + + return cleanTitle || cleanSku +} diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 3aa3e301..0cb79051 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -16,6 +16,7 @@ import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js' import type { JsonRecord } from '../../types/json.js' import { listKuaishouIndustryVouchersByOid, + listKuaishouIndustryVouchersByOids, listKuaishouIndustryVouchersByTaskId, } from '../../repositories/kuaishou-industry-voucher-repo.js' import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js' @@ -52,6 +53,7 @@ import { resolveAdminTaskScreenshotUrl, resolveDisplayShopName, resolveOrderItemDeliveryMode, + resolveOrderItemSkuNick, resolveOrderItemTitle, } from './admin-read-shared-helpers.js' import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js' @@ -96,18 +98,22 @@ export async function getAdminOrders( }) const orderIds = items.map((item) => item.id) - const [orderItems, tasks] = await Promise.all([ + const oids = items.map((item) => item.platform_order_id).filter(Boolean) + const [orderItems, tasks, vouchers] = await Promise.all([ listOrderItemsByOrderIds(orderIds), listTasksByOrderIds(orderIds), + listKuaishouIndustryVouchersByOids(oids), ]) const orderItemsByOrderId = groupRowsByOrderId(orderItems) const tasksByOrderId = groupRowsByOrderId(tasks) + const vouchersByOid = groupRowsByOid(vouchers) return { items: items.map((item) => mapAdminOrderListItem(item, { orderItems: orderItemsByOrderId.get(item.id) || [], tasks: tasksByOrderId.get(item.id) || [], + vouchers: vouchersByOid.get(item.platform_order_id) || [], }), ), pagination: { page, pageSize, total }, @@ -124,6 +130,16 @@ function groupRowsByOrderId(rows: T[]): Map(rows: T[]): Map { + const grouped = new Map() + for (const row of rows) { + const group = grouped.get(row.oid) || [] + group.push(row) + grouped.set(row.oid, group) + } + return grouped +} + export async function getAdminOrderDetail( orderId: AdminEntityIdInput, session: AdminViewerSessionInput | null = null, @@ -142,7 +158,9 @@ export async function getAdminOrderDetail( listTasksByOrderId(order.id), ]) const kuaishouIndustryVouchers = await listKuaishouIndustryVouchersByOid(order.platform_order_id) - const itemSummary = summarizeOrderItems(items) + const itemSummary = summarizeOrderItems(items, order, kuaishouIndustryVouchers) + const orderItemTitle = resolveOrderItemTitle(items[0], order, kuaishouIndustryVouchers) + const orderSkuNick = resolveOrderItemSkuNick(items[0], order, kuaishouIndustryVouchers) const viewerContext = createAdminViewerContext(session) return { @@ -165,6 +183,8 @@ export async function getAdminOrderDetail( createdAt: order.created_at, updatedAt: order.updated_at, itemSummary, + itemTitle: orderItemTitle, + skuNick: orderSkuNick, rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object' ? order.raw_payload_json @@ -175,7 +195,8 @@ export async function getAdminOrderDetail( orderItemId: item.id, skuCode: item.sku_code, skuName: item.sku_name, - itemTitle: resolveOrderItemTitle(item), + itemTitle: resolveOrderItemTitle(item, order, kuaishouIndustryVouchers), + skuNick: resolveOrderItemSkuNick(item, order, kuaishouIndustryVouchers), quantity: item.quantity, deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), spec: diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts index b4a7562c..65643c85 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts @@ -6,6 +6,8 @@ import { createAdminViewerContext, mapKuaishouCloudFulfillmentContext, resolveAdminTaskScreenshotUrl, + resolveOrderItemSkuNick, + resolveOrderItemTitle, } from './admin-read-shared-helpers.js' test('resolveAdminTaskScreenshotUrl lets support view final redeemed screenshot', async () => { @@ -86,3 +88,65 @@ test('mapKuaishouCloudFulfillmentContext exposes cloud account display labels', assert.deepEqual(flow?.binding.cloudSourceLabels, ['白班账号', '夜班账号']) assert.equal(flow?.binding.resolvedSourceLabel, '夜班账号') }) + +test('resolveOrderItemTitle and resolveOrderItemSkuNick with spec_json and snapshot', () => { + const item = { + id: 1, + order_id: 100, + sku_code: '26079757920762', + sku_name: '安卓血宴2钻', + quantity: 1, + spec_json: JSON.stringify({ + itemTitle: '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻', + skuNick: '安卓血宴2钻', + }), + item_snapshot_json: JSON.stringify({ + kuaishouSendCode: { + itemTitle: '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻', + skuNick: '安卓血宴2钻', + }, + }), + } + + assert.equal( + resolveOrderItemTitle(item), + '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻', + ) + assert.equal(resolveOrderItemSkuNick(item), '安卓血宴2钻') +}) + +test('resolveOrderItemTitle and resolveOrderItemSkuNick fallback to order raw_payload_json with stringified ext', () => { + const order = { + id: 100, + raw_payload_json: { + source: 'kuaishou_send_code', + body: { + itemTitle: '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻', + ext: '{"relItemId":26079757920762,"relSkuId":0,"skuNick":"安卓血宴2钻"}', + }, + }, + } as any + + assert.equal( + resolveOrderItemTitle(null, order), + '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪璀璨无暇1到7钻', + ) + assert.equal(resolveOrderItemSkuNick(null, order), '安卓血宴2钻') +}) + +test('resolveOrderItemTitle and resolveOrderItemSkuNick fallback to vouchers when order item and order payload are missing', () => { + const vouchers = [ + { + oid: '2625301761098133', + raw_payload_json: { + body: { + itemTitle: '测试商品标题', + ext: '{"skuNick":"测试SKU规格"}', + }, + }, + }, + ] as any + + assert.equal(resolveOrderItemTitle(null, null, vouchers), '测试商品标题') + assert.equal(resolveOrderItemSkuNick(null, null, vouchers), '测试SKU规格') +}) diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index a8322645..6e1c942b 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -8,7 +8,12 @@ import { import { canRegenerateClaimLinkStatus, isTaskFinalStatus } from '../../domain/task-status.js' import type { AdminViewerSessionInput } from '../../types/admin/read-inputs.js' -import type { OrderItemRow, TaskRow } from '../../types/repository/rows.js' +import type { + KuaishouIndustryVoucherRow, + OrderItemRow, + OrderRow, + TaskRow, +} from '../../types/repository/rows.js' type CloudSourceLabelMap = Map | Record @@ -422,23 +427,154 @@ export function parseTaskState(task: TaskLike | null | undefined): JsonRecord { return parseTaskStateValue(task) } -export function resolveOrderItemTitle(item: Partial | null | undefined): string { - if (!item) { - return '' +export function parseExtRecord(value: unknown): JsonRecord { + if (!value) return {} + if (typeof value === 'object' && !Array.isArray(value)) { + return value as JsonRecord + } + if (typeof value === 'string') { + return safeParseJson(value) + } + return {} +} + +export function resolveOrderItemTitle( + item: Partial | null | undefined, + fallbackOrder?: Partial | null | undefined, + vouchers?: KuaishouIndustryVoucherRow[] | null | undefined, +): string { + if (item) { + const spec = safeParseJson(item.spec_json) + const snapshot = safeParseJson(item.item_snapshot_json) + const ksSendCode = asJsonObject(snapshot.kuaishouSendCode) + const ksRawPayload = asJsonObject(snapshot.kuaishouSendCodeRawPayload) + const ksParams = asJsonObject(snapshot.params) + + const titleCandidate = pickFirstNonEmpty([ + spec.itemTitle, + spec.title, + spec.Title, + spec.item_title, + ksSendCode.itemTitle, + ksRawPayload.itemTitle, + ksParams.itemTitle, + snapshot.itemTitle, + spec.goods_name, + spec.goodsName, + spec.productName, + snapshot.productName, + ]) + + if (titleCandidate) { + return titleCandidate + } } - const spec = safeParseJson(item.spec_json) + if (fallbackOrder) { + const orderPayload = safeParseJson(fallbackOrder.raw_payload_json) + const body = asJsonObject( + orderPayload.body && typeof orderPayload.body === 'object' ? orderPayload.body : orderPayload, + ) + const fromOrder = pickFirstNonEmpty([ + body.itemTitle, + orderPayload.itemTitle, + body.productName, + orderPayload.productName, + body.title, + orderPayload.title, + ]) + if (fromOrder) { + return fromOrder + } + } - return pickFirstNonEmpty([ - spec.title, - spec.Title, - spec.itemTitle, - spec.item_title, - spec.goods_name, - spec.goodsName, - item.sku_name, - item.sku_code, - ]) + if (Array.isArray(vouchers) && vouchers.length > 0) { + for (const voucher of vouchers) { + const voucherPayload = safeParseJson(voucher.raw_payload_json) + const body = asJsonObject( + voucherPayload.body && typeof voucherPayload.body === 'object' + ? voucherPayload.body + : voucherPayload, + ) + const fromVoucher = pickFirstNonEmpty([body.itemTitle, voucherPayload.itemTitle, body.title]) + if (fromVoucher) { + return fromVoucher + } + } + } + + if (item) { + return pickFirstNonEmpty([item.sku_name, item.sku_code]) + } + + return '' +} + +export function resolveOrderItemSkuNick( + item: Partial | null | undefined, + fallbackOrder?: Partial | null | undefined, + vouchers?: KuaishouIndustryVoucherRow[] | null | undefined, +): string { + if (item) { + const spec = safeParseJson(item.spec_json) + const snapshot = safeParseJson(item.item_snapshot_json) + const ksSendCode = asJsonObject(snapshot.kuaishouSendCode) + const ksRawPayload = asJsonObject(snapshot.kuaishouSendCodeRawPayload) + const ksParams = asJsonObject(snapshot.params) + const extFromSnapshot = parseExtRecord(ksRawPayload.ext || ksParams.ext || snapshot.ext) + const extFromSpec = parseExtRecord(spec.ext) + + const candidateSkuNick = pickFirstNonEmpty([ + spec.skuNick, + spec.sku_nick, + ksSendCode.skuNick, + extFromSnapshot.skuNick, + extFromSpec.skuNick, + snapshot.externalSkuName, + snapshot.externalSkuNameNormalized, + snapshot.skuNick, + spec.skuName, + ]) + + if (candidateSkuNick) { + return candidateSkuNick + } + + const resolvedTitle = resolveOrderItemTitle(item, fallbackOrder, vouchers) + if (item.sku_name && item.sku_name.trim() !== resolvedTitle) { + return item.sku_name.trim() + } + } + + if (fallbackOrder) { + const orderPayload = safeParseJson(fallbackOrder.raw_payload_json) + const body = asJsonObject( + orderPayload.body && typeof orderPayload.body === 'object' ? orderPayload.body : orderPayload, + ) + const ext = parseExtRecord(body.ext || orderPayload.ext) + const fromOrder = pickFirstNonEmpty([ext.skuNick, body.skuNick, orderPayload.skuNick]) + if (fromOrder) { + return fromOrder + } + } + + if (Array.isArray(vouchers) && vouchers.length > 0) { + for (const voucher of vouchers) { + const voucherPayload = safeParseJson(voucher.raw_payload_json) + const body = asJsonObject( + voucherPayload.body && typeof voucherPayload.body === 'object' + ? voucherPayload.body + : voucherPayload, + ) + const ext = parseExtRecord(body.ext || voucherPayload.ext) + const fromVoucher = pickFirstNonEmpty([ext.skuNick, body.skuNick, voucherPayload.skuNick]) + if (fromVoucher) { + return fromVoucher + } + } + } + + return '' } export function resolveOrderItemDeliveryMode( 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 index 28037191..cf0a6303 100644 --- 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 @@ -130,6 +130,7 @@ export async function syncWorkOrdersFromKuaishouSendCode( if (order) { const orderItems = await applyKuaishouSendCodeItemData( + order.id, await listOrderItemsByOrderId(order.id), sourceData, input.rawParams, @@ -381,6 +382,7 @@ export function resolveKuaishouSendCodeShopName( } async function applyKuaishouSendCodeItemData( + orderId: number, orderItems: OrderItemRow[], sourceData: KuaishouSendCodeWorkOrderData, rawPayload: JsonObject, @@ -390,6 +392,34 @@ async function applyKuaishouSendCodeItemData( return orderItems } + if (orderItems.length === 0) { + return replaceOrderItems(orderId, [ + { + skuCode: sourceData.itemId || sourceData.skuId || 'default', + skuName: sourceData.skuNick || sourceData.itemTitle, + quantity: sourceData.quantity || 1, + specJson: JSON.stringify({ + sellerId: sourceData.sellerId, + skuId: sourceData.skuId, + itemTitle: sourceData.itemTitle, + relItemId: sourceData.relItemId, + relSkuId: sourceData.relSkuId, + skuNick: sourceData.skuNick, + quantity: sourceData.quantity, + paymentFen: sourceData.paymentFen, + source: KUAISHOU_SEND_CODE_SOURCE, + }), + itemSnapshotJson: JSON.stringify({ + source: KUAISHOU_SEND_CODE_SOURCE, + params: rawPayload, + kuaishouSendCode: sourceData, + }), + createdAt: now, + updatedAt: now, + }, + ]) + } + const matchedIds = findKuaishouSendCodeItemIds(orderItems, sourceData) if (matchedIds.size === 0) { return orderItems @@ -402,6 +432,12 @@ async function applyKuaishouSendCodeItemData( } const snapshot = safeParseJson(item.item_snapshot_json) + const spec = safeParseJson(item.spec_json) + const mergedSpec = { + ...spec, + itemTitle: sourceData.itemTitle || spec.itemTitle, + skuNick: sourceData.skuNick || spec.skuNick, + } const snapshotJson = { ...item, item_snapshot_json: { @@ -417,6 +453,7 @@ async function applyKuaishouSendCodeItemData( const updated = await updateOrderItemSourceSnapshot(item.id, { skuName: sourceData.skuNick || item.sku_name, itemSnapshotJson: JSON.stringify(snapshotJson.item_snapshot_json), + specJson: JSON.stringify(mergedSpec), updatedAt: now, }) return updated || item diff --git a/apps/backend/src/types/admin/read-models.ts b/apps/backend/src/types/admin/read-models.ts index cc494b28..772fbc20 100644 --- a/apps/backend/src/types/admin/read-models.ts +++ b/apps/backend/src/types/admin/read-models.ts @@ -6,6 +6,12 @@ export type AdminOrderFulfillmentProgress = { customerStatus: string } +export type AdminOrderItemSummary = { + itemTitle: string + skuNick: string + quantity: number +} + export type AdminOrderListItem = { orderId: number provider: string @@ -27,6 +33,9 @@ export type AdminOrderListItem = { itemCount: number totalQuantity: number itemSummary: string + itemTitle?: string + skuNick?: string + items?: AdminOrderItemSummary[] taskCount: number resourceStatus: string customerStatus: string diff --git a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx index e783d40e..46148dc2 100644 --- a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx @@ -1,5 +1,5 @@ import { ArrowLeftOutlined } from '@ant-design/icons' -import { Alert, Button, Card, Descriptions, Skeleton, Space, Table, Typography } from 'antd' +import { Alert, Button, Card, Descriptions, Skeleton, Space, Table, Tag, Typography } from 'antd' import type { TableColumnsType } from 'antd' import { useQuery } from '@tanstack/react-query' import { useNavigate, useParams } from 'react-router' @@ -115,7 +115,16 @@ export default function AdminOrderDetailPage() { {formatAdminDateTime(detail.order.updatedAt)} - {detail.order.itemSummary || '-'} + +
+ {detail.order.itemTitle || detail.order.itemSummary || '-'} + {detail.order.skuNick ? ( + + {detail.order.skuNick} + + ) : null} +
+
@@ -126,8 +135,26 @@ export default function AdminOrderDetailPage() { dataSource={detail.items} scroll={{ x: 760 }} columns={[ - { title: '商品', dataIndex: 'itemTitle', minWidth: 240 }, - { title: 'SKU', dataIndex: 'skuCode', minWidth: 180 }, + { + title: '商品', + dataIndex: 'itemTitle', + minWidth: 240, + render: (value) => {value || '-'}, + }, + { + title: 'SKU / 规格', + minWidth: 180, + render: (_, item) => ( +
+ {item.skuNick ? ( + + {item.skuNick} + + ) : null} + {item.skuCode || item.skuName || '-'} +
+ ), + }, { title: '数量', dataIndex: 'quantity', width: 90 }, { title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 }, ]} diff --git a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx index edce0352..291b2f9f 100644 --- a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx @@ -1,5 +1,5 @@ import { SearchOutlined, ReloadOutlined } from '@ant-design/icons' -import { Button, Card, Form, Input, Select, Space, Table, Typography } from 'antd' +import { Button, Card, Form, Input, Select, Space, Table, Tag, Typography } from 'antd' import type { TableColumnsType } from 'antd' import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' @@ -102,15 +102,76 @@ export default function AdminOrdersPage() { }, { title: '商品 / 任务', - minWidth: 220, - render: (_, row) => ( -
- {row.itemSummary || '-'} - - 商品 {row.itemCount} · 数量 {row.totalQuantity} · 任务 {row.taskCount} - -
- ), + minWidth: 260, + render: (_, row) => { + const itemTitle = row.itemTitle || row.itemSummary || '-' + const skuNick = row.skuNick + const items = row.items && row.items.length > 0 ? row.items : null + + return ( +
+ {items && items.length > 1 ? ( +
+ {items.slice(0, 2).map((item, idx) => ( +
+ + {item.itemTitle || '-'} + + {item.skuNick ? ( + + + {item.skuNick} + + + ) : null} +
+ ))} + {items.length > 2 ? 等 {items.length} 种商品 : null} +
+ ) : ( + <> + + {itemTitle} + + {skuNick ? ( + + + {skuNick} + + + ) : null} + + )} + + 商品 {row.itemCount} · 数量 {row.totalQuantity} · 任务 {row.taskCount} + +
+ ) + }, }, { title: '状态', diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts index c41d39aa..681ad549 100644 --- a/apps/frontend/src/types/admin/orders.ts +++ b/apps/frontend/src/types/admin/orders.ts @@ -8,6 +8,12 @@ export interface AdminOrderFulfillmentProgress { customerStatus: string } +export interface AdminOrderItemSummary { + itemTitle: string + skuNick: string + quantity: number +} + export interface AdminOrderListItem { orderId: number provider: string @@ -29,6 +35,9 @@ export interface AdminOrderListItem { itemCount: number totalQuantity: number itemSummary: string + itemTitle?: string + skuNick?: string + items?: AdminOrderItemSummary[] taskCount: number resourceStatus: string customerStatus: string @@ -56,6 +65,8 @@ export interface AdminOrderDetail { createdAt: string updatedAt: string itemSummary: string + itemTitle?: string + skuNick?: string rawPayload: Record fulfillmentProgress: AdminOrderFulfillmentProgress } @@ -64,6 +75,7 @@ export interface AdminOrderDetail { skuCode: string skuName: string itemTitle: string + skuNick?: string quantity: number deliveryMode: string spec: Record