diff --git a/apps/backend/src/services/fulfillment/delivery-link-service.test.ts b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts new file mode 100644 index 00000000..c36dd177 --- /dev/null +++ b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { buildClaimUrl } from '../claim/claim-service.js' +import { resolveTaskDeliveryLink } from './delivery-link-service.js' +import type { TaskRow } from '../../types/repository/rows.js' + +test('resolveTaskDeliveryLink 为 cloud 任务返回内部领取链接', async () => { + const result = await resolveTaskDeliveryLink(createTask({ + executor_key: 'kuaishou_ct_assisted', + primary_claim_token: 'cloud-token', + primary_claim_expires_at: '2026-07-09T00:00:00.000Z', + })) + + assert.deepEqual(result, { + claimUrl: buildClaimUrl('cloud-token'), + expireTime: '2026-07-09T00:00:00.000Z', + }) +}) + +test('resolveTaskDeliveryLink 兼容历史 kuaishou-industry cloud 任务', async () => { + const result = await resolveTaskDeliveryLink(createTask({ + executor_key: 'kuaishou-industry', + claim_token: 'industry-token', + claim_expires_at: '2026-07-09T08:00:00.000Z', + })) + + assert.deepEqual(result, { + claimUrl: buildClaimUrl('industry-token'), + expireTime: '2026-07-09T08:00:00.000Z', + }) +}) + +test('resolveTaskDeliveryLink 为 feifei 任务返回已保存短链', async () => { + const result = await resolveTaskDeliveryLink(createTask({ + executor_key: 'kuaishou_feifei', + context_json: { + kuaishouFeifei: { + shortLink: { + code: 'AbCd1234', + url: 'https://ks.khhao.com/s/AbCd1234', + targetUrl: 'https://feifei.example.com/h5/bind?code=very-long', + }, + h5: { + rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long', + }, + }, + }, + })) + + assert.deepEqual(result, { + claimUrl: 'https://ks.khhao.com/s/AbCd1234', + expireTime: '', + }) +}) + +test('resolveTaskDeliveryLink 对人工履约任务返回 null', async () => { + const result = await resolveTaskDeliveryLink(createTask({ + executor_key: 'manual_dispatch', + })) + + assert.equal(result, null) +}) + +function createTask(patch: Partial = {}): TaskRow { + return { + id: 123, + order_id: 456, + order_item_id: 789, + unit_index: 1, + platform_order_id: '2614900602069169', + profile_id: 0, + task_no: 'DT-test', + executor_key: 'kuaishou_ct_assisted', + task_status: 'pending_binding_prepare', + delivery_status: 'pending', + result_code: '', + result_message: '', + automation_mode: 'manual', + requires_claim: true, + user_action_status: 'pending_claim', + attempt_count: 0, + runtime_session_id: '', + login_type: '', + nickname: '', + role_name: '', + role_id: '', + area: '', + partition_name: '', + claim_token: '', + primary_claim_token: '', + primary_claim_token_id: null, + primary_claim_token_status: '', + artifacts_json: '{}', + context_json: '{}', + screenshot_path: '', + last_error: '', + retry_count: 0, + created_at: '2026-07-08T00:00:00.000Z', + updated_at: '2026-07-08T00:00:00.000Z', + claimed_at: null, + role_confirmed_at: null, + redeemed_at: null, + ...patch, + } +} diff --git a/apps/backend/src/services/fulfillment/delivery-link-service.ts b/apps/backend/src/services/fulfillment/delivery-link-service.ts new file mode 100644 index 00000000..8221fa71 --- /dev/null +++ b/apps/backend/src/services/fulfillment/delivery-link-service.ts @@ -0,0 +1,11 @@ +import { resolveFulfillmentDeliveryLink } from './executors/registry.js' +import type { FulfillmentDeliveryLink } from './executors/types.js' +import type { TaskRow } from '../../types/repository/rows.js' + +export type TaskDeliveryLink = FulfillmentDeliveryLink + +export async function resolveTaskDeliveryLink( + task: TaskRow, +): Promise { + return resolveFulfillmentDeliveryLink(task) +} diff --git a/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts b/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts new file mode 100644 index 00000000..55f16afb --- /dev/null +++ b/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts @@ -0,0 +1,73 @@ +import { buildClaimUrl } from '../../claim/claim-service.js' +import { + shouldEnsureKuaishouCloudClaimLink, + TASK_STATUS, +} from '../../../domain/task-status.js' +import { ensureTaskClaimLink } from '../kuaishou-cloud/index.js' +import { + FULFILLMENT_EXECUTOR_KEYS, + type FulfillmentDeliveryLink, + type FulfillmentExecutor, + type FulfillmentPrepareDeps, +} from './types.js' +import type { TaskRow } from '../../../types/repository/rows.js' + +export const kuaishouCloudExecutor: FulfillmentExecutor = { + key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, + preparePaidTask, + resolveDeliveryLink, +} + +async function preparePaidTask( + task: TaskRow, + deps: FulfillmentPrepareDeps, +): Promise { + const now = deps.nowIso() + + if (shouldEnsureKuaishouCloudClaimLink(task.task_status)) { + if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) { + const claimToken = await deps.createTaskClaimToken(task.id) + return deps.updateTask(task.id, { + claim_token: claimToken.token, + claim_expires_at: claimToken.expired_at, + user_action_status: 'pending_claim', + updated_at: now, + }) + } + + return task + } + + const claimToken = await deps.createTaskClaimToken(task.id) + + return deps.updateTask(task.id, { + task_status: TASK_STATUS.PENDING_BINDING_PREPARE, + claim_token: claimToken.token, + claim_expires_at: claimToken.expired_at, + user_action_status: 'pending_claim', + last_error: task.last_error || '领取链接已生成,等待客户提交核销码', + updated_at: now, + }) +} + +async function resolveDeliveryLink(task: TaskRow): Promise { + const primaryToken = String(task.primary_claim_token || task.claim_token || '').trim() + const expireTime = task.primary_claim_expires_at || task.claim_expires_at || '' + + if (primaryToken) { + return { + claimUrl: buildClaimUrl(primaryToken), + expireTime, + } + } + + try { + const ensured = await ensureTaskClaimLink(task) + return { + claimUrl: String(ensured.claimUrl || '').trim(), + expireTime: ensured.expiredAt || expireTime, + } + } catch { + return null + } +} diff --git a/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts b/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts new file mode 100644 index 00000000..5e1b450a --- /dev/null +++ b/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts @@ -0,0 +1,62 @@ +import { TASK_STATUS } from '../../../domain/task-status.js' +import { + ensureKuaishouFeifeiClaimShortLink, + prepareKuaishouFeifeiTask, + resolveKuaishouFeifeiClaimUrl, +} from '../kuaishou-feifei/index.js' +import { parseTaskContext } from '../../../utils/task-json.js' +import { + FULFILLMENT_EXECUTOR_KEYS, + type FulfillmentDeliveryLink, + type FulfillmentExecutor, + type FulfillmentPrepareDeps, +} from './types.js' +import type { TaskRow } from '../../../types/repository/rows.js' + +export const kuaishouFeifeiExecutor: FulfillmentExecutor = { + key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, + preparePaidTask, + resolveDeliveryLink, +} + +async function preparePaidTask( + task: TaskRow, + deps: FulfillmentPrepareDeps, +): Promise { + try { + return await prepareKuaishouFeifeiTask(task) + } catch (error) { + const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败' + const updatedTask = await deps.updateTask(task.id, { + task_status: TASK_STATUS.MANUAL_REVIEW, + user_action_status: 'not_required', + last_error: message, + result_code: 'kuaishou_feifei_prepare_failed', + result_message: message, + updated_at: deps.nowIso(), + }) + await deps.notifyTaskAutoManualReview({ + task: updatedTask || task, + reason: message, + source: 'kuaishou_feifei_prepare_failed', + }) + return updatedTask + } +} + +async function resolveDeliveryLink(task: TaskRow): Promise { + let claimUrl = await ensureKuaishouFeifeiClaimShortLink(task) + if (!claimUrl) { + const context = parseTaskContext(task) + claimUrl = resolveKuaishouFeifeiClaimUrl(context.kuaishouFeifei) + } + + if (!claimUrl) { + return null + } + + return { + claimUrl, + expireTime: '', + } +} diff --git a/apps/backend/src/services/fulfillment/executors/manual-executor.ts b/apps/backend/src/services/fulfillment/executors/manual-executor.ts new file mode 100644 index 00000000..a93bf178 --- /dev/null +++ b/apps/backend/src/services/fulfillment/executors/manual-executor.ts @@ -0,0 +1,33 @@ +import { TASK_STATUS } from '../../../domain/task-status.js' +import { + FULFILLMENT_EXECUTOR_KEYS, + type FulfillmentExecutor, + type FulfillmentPrepareDeps, +} from './types.js' +import type { TaskRow } from '../../../types/repository/rows.js' + +export const manualDispatchExecutor: FulfillmentExecutor = { + key: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, + preparePaidTask, +} + +async function preparePaidTask( + task: TaskRow, + deps: FulfillmentPrepareDeps, +): Promise { + const lastError = task.last_error || '当前任务需要人工履约处理' + const updatedTask = await deps.updateTask(task.id, { + task_status: TASK_STATUS.MANUAL_REVIEW, + user_action_status: 'not_required', + last_error: lastError, + updated_at: deps.nowIso(), + }) + + await deps.notifyTaskAutoManualReview({ + task: updatedTask || task, + reason: lastError, + source: 'manual_dispatch_profile', + }) + + return updatedTask +} diff --git a/apps/backend/src/services/fulfillment/executors/registry.ts b/apps/backend/src/services/fulfillment/executors/registry.ts new file mode 100644 index 00000000..697754ac --- /dev/null +++ b/apps/backend/src/services/fulfillment/executors/registry.ts @@ -0,0 +1,55 @@ +import { isPaidPreparationStableStatus } from '../../../domain/task-status.js' +import { kuaishouCloudExecutor } from './kuaishou-cloud-executor.js' +import { kuaishouFeifeiExecutor } from './kuaishou-feifei-executor.js' +import { manualDispatchExecutor } from './manual-executor.js' +import { + FULFILLMENT_EXECUTOR_KEYS, + isManualDispatchExecutor, + normalizeExecutorKey, + type FulfillmentDeliveryLink, + type FulfillmentExecutor, + type FulfillmentPrepareDeps, +} from './types.js' +import type { TaskRow } from '../../../types/repository/rows.js' + +const EXECUTORS = new Map([ + [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, kuaishouCloudExecutor], + [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY, kuaishouCloudExecutor], + [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, kuaishouFeifeiExecutor], + [FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, manualDispatchExecutor], +]) + +export function getFulfillmentExecutor(executorKey: unknown): FulfillmentExecutor | null { + return EXECUTORS.get(normalizeExecutorKey(executorKey)) || null +} + +export async function preparePaidFulfillmentTask( + task: TaskRow, + deps: FulfillmentPrepareDeps, +): Promise { + if (isPaidPreparationStableStatus(task.task_status)) { + return task + } + + const executor = getFulfillmentExecutor(task.executor_key) + if (executor?.preparePaidTask) { + return executor.preparePaidTask(task, deps) + } + + if (!task.requires_claim || isManualDispatchExecutor(task.executor_key)) { + return manualDispatchExecutor.preparePaidTask?.(task, deps) || task + } + + return task +} + +export async function resolveFulfillmentDeliveryLink( + task: TaskRow, +): Promise { + const executor = getFulfillmentExecutor(task.executor_key) + if (!executor?.resolveDeliveryLink) { + return null + } + + return executor.resolveDeliveryLink(task) +} diff --git a/apps/backend/src/services/fulfillment/executors/types.ts b/apps/backend/src/services/fulfillment/executors/types.ts new file mode 100644 index 00000000..ad4e62bb --- /dev/null +++ b/apps/backend/src/services/fulfillment/executors/types.ts @@ -0,0 +1,59 @@ +import type { TaskUpdatePatch } from '../../../types/repository/inputs.js' +import type { TaskRow } from '../../../types/repository/rows.js' + +export const FULFILLMENT_EXECUTOR_KEYS = { + MANUAL_DISPATCH: 'manual_dispatch', + KUAISHOU_CLOUD: 'kuaishou_ct_assisted', + KUAISHOU_INDUSTRY: 'kuaishou-industry', + KUAISHOU_FEIFEI: 'kuaishou_feifei', +} as const + +export type FulfillmentExecutorKey = + (typeof FULFILLMENT_EXECUTOR_KEYS)[keyof typeof FULFILLMENT_EXECUTOR_KEYS] | (string & {}) + +export type FulfillmentDeliveryLink = { + claimUrl: string + expireTime?: unknown +} + +export type FulfillmentPrepareDeps = { + updateTask: (taskId: number | string, patch: TaskUpdatePatch) => Promise + createTaskClaimToken: (taskId: number | string) => Promise<{ + token: string + expired_at: string + [key: string]: unknown + }> + notifyTaskAutoManualReview: (payload: { + task: unknown + reason: string + source: string + }) => Promise | unknown + nowIso: () => string +} + +export type FulfillmentExecutor = { + key: FulfillmentExecutorKey + preparePaidTask?: ( + task: TaskRow, + deps: FulfillmentPrepareDeps, + ) => Promise + resolveDeliveryLink?: (task: TaskRow) => Promise +} + +export function normalizeExecutorKey(value: unknown): FulfillmentExecutorKey { + return String(value || '').trim() as FulfillmentExecutorKey +} + +export function isKuaishouCloudExecutor(value: unknown): boolean { + const executorKey = normalizeExecutorKey(value) + return executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD || + executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY +} + +export function isKuaishouFeifeiExecutor(value: unknown): boolean { + return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI +} + +export function isManualDispatchExecutor(value: unknown): boolean { + return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts index 92cb3120..4d46a43b 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts @@ -1,5 +1,5 @@ import { createHttpError } from "../../../utils/http.js"; -import { normalizeProductName } from "../../order/product-match-service.js"; +import { normalizeProductName } from "../product-resolution-service.js"; import { appointCloudtentaclesVirtualNumber, backCloudtentaclesVirtualNumber, diff --git a/apps/backend/src/services/fulfillment/order-fulfillment-readiness-service.test.ts b/apps/backend/src/services/fulfillment/order-fulfillment-readiness-service.test.ts new file mode 100644 index 00000000..f04d164b --- /dev/null +++ b/apps/backend/src/services/fulfillment/order-fulfillment-readiness-service.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + ORDER_FULFILLMENT_BLOCK_REASONS, + resolveOrderFulfillmentReadiness, +} from './order-fulfillment-readiness-service.js' +import type { KuaishouIndustryVoucherRow } from '../../types/repository/rows.js' + +test('resolveOrderFulfillmentReadiness 无电子凭证时允许继续履约', async () => { + const readiness = await resolveOrderFulfillmentReadiness( + { platform_order_id: '2614900602069169' }, + { + listKuaishouIndustryVouchersByOid: async () => [], + }, + ) + + assert.deepEqual(readiness, { + allowed: true, + voucherCount: 0, + reason: '', + blockReason: '', + }) +}) + +test('resolveOrderFulfillmentReadiness 电子凭证发码成功时允许继续履约', async () => { + const readiness = await resolveOrderFulfillmentReadiness( + { platform_order_id: '2614900602069169' }, + { + listKuaishouIndustryVouchersByOid: async () => [ + createVoucher({ send_callback_status: 'success' }), + ], + }, + ) + + assert.deepEqual(readiness, { + allowed: true, + voucherCount: 1, + reason: '', + blockReason: '', + }) +}) + +test('resolveOrderFulfillmentReadiness 电子凭证发码未确认时阻止履约', async () => { + const readiness = await resolveOrderFulfillmentReadiness( + { platform_order_id: '2614900602069169' }, + { + listKuaishouIndustryVouchersByOid: async () => [ + createVoucher({ + send_callback_status: 'pending', + send_callback_last_error: '回调等待中', + }), + ], + }, + ) + + assert.deepEqual(readiness, { + allowed: false, + voucherCount: 1, + reason: '回调等待中', + blockReason: ORDER_FULFILLMENT_BLOCK_REASONS.KUAISHOU_INDUSTRY_SEND_CALLBACK_UNCONFIRMED, + }) +}) + +function createVoucher(patch: Partial = {}): KuaishouIndustryVoucherRow { + return { + id: 1, + voucher_code: 'ETICKET-1', + oid: '2614900602069169', + order_id: null, + task_id: null, + unit_index: 1, + seller_id: '', + token: '', + status: 'UNUSED', + valid_start_time: 0, + valid_end_time: 0, + consume_serial_num: '', + consume_details_json: [], + consumed_at: null, + destroyed_at: null, + send_callback_status: 'success', + send_callback_attempt_count: 0, + send_callback_last_error: '', + send_callback_response_json: {}, + send_callback_sent_at: null, + raw_payload_json: {}, + created_at: '2026-07-08T00:00:00.000Z', + updated_at: '2026-07-08T00:00:00.000Z', + ...patch, + } +} diff --git a/apps/backend/src/services/fulfillment/order-fulfillment-readiness-service.ts b/apps/backend/src/services/fulfillment/order-fulfillment-readiness-service.ts new file mode 100644 index 00000000..c86026af --- /dev/null +++ b/apps/backend/src/services/fulfillment/order-fulfillment-readiness-service.ts @@ -0,0 +1,63 @@ +import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js' +import { bindKuaishouIndustryVouchersToOrderTasks } from '../platforms/kuaishou-industry/voucher-binding-service.js' +import { + isKuaishouIndustryVoucherSendCallbackSuccess, + resolveKuaishouIndustryVoucherSendCallbackMessage, +} from '../platforms/kuaishou-industry/voucher-service.js' +import type { KuaishouIndustryVoucherRow, OrderRow, TaskRow } from '../../types/repository/rows.js' + +export const ORDER_FULFILLMENT_BLOCK_REASONS = { + KUAISHOU_INDUSTRY_SEND_CALLBACK_UNCONFIRMED: 'kuaishou_industry_send_callback_unconfirmed', +} as const + +type FulfillmentReadinessDeps = { + listKuaishouIndustryVouchersByOid?: typeof listKuaishouIndustryVouchersByOid +} + +export async function resolveOrderFulfillmentReadiness( + order: Pick | null | undefined, + deps: FulfillmentReadinessDeps = {}, +) { + const listVouchers = deps.listKuaishouIndustryVouchersByOid || listKuaishouIndustryVouchersByOid + const oid = String(order?.platform_order_id || '').trim() + if (!oid) { + return { + allowed: true, + voucherCount: 0, + reason: '', + blockReason: '', + } + } + + const vouchers = await listVouchers(oid) + if (vouchers.length === 0 || vouchers.every(isKuaishouIndustryVoucherSendCallbackSuccess)) { + return { + allowed: true, + voucherCount: vouchers.length, + reason: '', + blockReason: '', + } + } + + const blockedVoucher = vouchers.find((voucher) => !isKuaishouIndustryVoucherSendCallbackSuccess(voucher)) + + return { + allowed: false, + voucherCount: vouchers.length, + reason: blockedVoucher + ? resolveKuaishouIndustryVoucherSendCallbackMessage(blockedVoucher) + : '电子凭证发码回调未确认', + blockReason: ORDER_FULFILLMENT_BLOCK_REASONS.KUAISHOU_INDUSTRY_SEND_CALLBACK_UNCONFIRMED, + } +} + +export async function syncOrderFulfillmentAttachments( + order: OrderRow, + tasks: TaskRow[] = [], + options: { + source?: string + now?: string + } = {}, +): Promise { + return bindKuaishouIndustryVouchersToOrderTasks(order, tasks, options) +} diff --git a/apps/backend/src/services/fulfillment/planner.test.ts b/apps/backend/src/services/fulfillment/planner.test.ts new file mode 100644 index 00000000..5d6664b4 --- /dev/null +++ b/apps/backend/src/services/fulfillment/planner.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { planFulfillmentTaskForOrderItem } from './planner.js' +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' + +test('planFulfillmentTaskForOrderItem 为 cloudtentacles 商品生成 cloud 履约计划', async () => { + const plan = await planFulfillmentTaskForOrderItem({ + order: createOrder(), + item: createOrderItem({ + sku_code: '荣耀勋章礼包(30个)', + sku_name: '荣耀勋章礼包(30个)', + item_snapshot_json: { + cloudtentacles: { + matchMode: 'cloudtentacles_name', + normalizedProductName: '荣耀勋章礼包(30个)', + cloudSourceKeys: ['account-a'], + cloudSkuId: 74, + cloudSkuName: '荣耀勋章礼包(30个)', + }, + }, + }), + getProfileByKey: async (profileKey) => ({ + id: 9, + profile_key: profileKey, + name: '快手 cloud 履约', + executor_key: profileKey, + requires_claim: true, + auto_dispatch: true, + }), + }) + + assert.ok(plan) + assert.equal(plan.executorKey, 'kuaishou_ct_assisted') + assert.equal(plan.requiresClaim, false) + assert.equal(plan.autoDispatch, false) + assert.equal(plan.context.profileKey, 'kuaishou_ct_assisted') + assert.equal( + (plan.context.kuaishouCloudFulfillment as any).binding.resolvedSourceKey, + 'account-a', + ) + assert.equal((plan.context.kuaishouCloudFulfillment as any).binding.skuId, 74) + assert.equal(plan.context.kuaishouFeifei, null) +}) + +test('planFulfillmentTaskForOrderItem 为 feifei 商品生成 feifei 履约计划', async () => { + const plan = await planFulfillmentTaskForOrderItem({ + order: createOrder(), + item: createOrderItem({ + sku_code: 'FF-1001', + sku_name: '测试皮肤', + item_snapshot_json: { + kuaishouFeifei: { + productCode: 'FF-1001', + skuName: '测试皮肤', + matchMode: 'kuaishou_feifei_name', + }, + }, + }), + getProfileByKey: async (profileKey) => ({ + id: 10, + profile_key: profileKey, + name: 'kuaishou-feifei 履约', + executor_key: profileKey, + requires_claim: false, + auto_dispatch: true, + }), + }) + + assert.ok(plan) + assert.equal(plan.executorKey, 'kuaishou_feifei') + assert.equal(plan.requiresClaim, true) + assert.equal(plan.autoDispatch, false) + assert.equal(plan.context.kuaishouCloudFulfillment, null) + assert.equal((plan.context.kuaishouFeifei as any).productCode, 'FF-1001') + assert.equal((plan.context.kuaishouFeifei as any).productName, '测试皮肤') +}) + +test('planFulfillmentTaskForOrderItem 未命中平台配置时返回 null', async () => { + const plan = await planFulfillmentTaskForOrderItem({ + order: createOrder(), + item: createOrderItem({ + item_snapshot_json: { + cloudtentacles: null, + kuaishouFeifei: null, + }, + }), + getProfileByKey: async () => null, + }) + + assert.equal(plan, null) +}) + +function createOrder(patch: Partial = {}): OrderRow { + return { + id: 1, + provider: '91kaquan', + platform: 'kuaishou', + shop_id: '91kaquan', + shop_name: '91卡券', + platform_order_id: '2614900602069169', + order_status: 'paid', + pay_status: 'paid', + buyer_id: '', + buyer_name: '', + receiver_contact: '', + total_amount: 0, + currency: 'CNY', + raw_payload_json: '{}', + paid_at: '2026-07-08T00:00:00.000Z', + created_at: '2026-07-08T00:00:00.000Z', + updated_at: '2026-07-08T00:00:00.000Z', + ...patch, + } +} + +function createOrderItem(patch: Partial = {}): OrderItemRow { + return { + id: 10, + order_id: 1, + sku_code: 'SKU-1', + sku_name: '测试商品', + quantity: 1, + spec_json: '{}', + item_snapshot_json: {}, + created_at: '2026-07-08T00:00:00.000Z', + updated_at: '2026-07-08T00:00:00.000Z', + ...patch, + } +} diff --git a/apps/backend/src/services/fulfillment/planner.ts b/apps/backend/src/services/fulfillment/planner.ts new file mode 100644 index 00000000..bb828957 --- /dev/null +++ b/apps/backend/src/services/fulfillment/planner.ts @@ -0,0 +1,436 @@ +import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js' +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' +import { + FULFILLMENT_EXECUTOR_KEYS, + isKuaishouCloudExecutor, + isKuaishouFeifeiExecutor, +} from './executors/types.js' + +type JsonObject = Record + +export type FulfillmentBindingLike = { + id: number + profile_id?: number + profile_key?: string + profile_name?: string + name?: string + executor_key?: string + requires_claim?: boolean + auto_dispatch?: boolean + config_json?: string | JsonObject + [key: string]: unknown +} + +export type FulfillmentProfileResolver = ( + profileKey: string, +) => Promise + +export type FulfillmentTaskPlan = { + profile: FulfillmentBindingLike + profileId: number + profileKey: string + profileName: string + executorKey: string + requiresClaim: boolean + autoDispatch: boolean + context: JsonObject +} + +export async function planFulfillmentTaskForOrderItem({ + order, + item, + getProfileByKey = getFulfillmentProfileByKey, +}: { + order: OrderRow + item: OrderItemRow + getProfileByKey?: FulfillmentProfileResolver +}): Promise { + const profile = await resolveDynamicFulfillmentProfile(item, getProfileByKey) + + if (!profile) { + return null + } + + const profileId = Number(profile.profile_id || profile.id || 0) + const profileKey = String(profile.profile_key || '').trim() + const profileName = String(profile.profile_name || profile.name || '').trim() + const executorKey = String(profile.executor_key || FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) + .trim() + || FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH + const fulfillmentConfig = parseJsonObject(profile.config_json) + const context = buildFulfillmentTaskContext({ + order, + item, + profile, + profileKey, + profileName, + executorKey, + fulfillmentConfig, + }) + + return { + profile, + profileId, + profileKey, + profileName, + executorKey, + requiresClaim: Boolean(profile.requires_claim), + autoDispatch: Boolean(profile.auto_dispatch), + context, + } +} + +function buildFulfillmentTaskContext({ + order, + item, + profileKey, + profileName, + executorKey, + fulfillmentConfig, +}: { + order: OrderRow + item: OrderItemRow + profile: FulfillmentBindingLike + profileKey: string + profileName: string + executorKey: string + fulfillmentConfig: JsonObject +}) { + const itemSnapshot = parseJsonObject(item.item_snapshot_json) + const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) + const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei) + const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume) + const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop) + const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys) + const resolvedCloudSourceKey = + cloudSourceKeys.length === 1 + ? String(cloudtentaclesConfig.resolvedSourceKey || cloudSourceKeys[0] || '').trim() + : '' + const deliveryItems = normalizeCloudDeliveryItems(cloudtentaclesConfig) + const primaryDeliveryItem = deliveryItems[0] || { + cloudSkuId: Number(cloudtentaclesConfig.skuId || 0) || 0, + cloudSkuName: String(cloudtentaclesConfig.skuName || '').trim(), + quantity: 1, + } + + return { + profileKey, + profileName, + skuCode: item.sku_code, + skuName: item.sku_name, + kuaishouCloudFulfillment: isKuaishouCloudExecutor(executorKey) + ? { + flowType: 'kuaishou_cloud_fulfillment', + configId: String(fulfillmentConfig.configId || '').trim(), + internalSkuCode: item.sku_code, + internalSkuName: item.sku_name, + deliveryItems, + ticket: { + code: '', + status: 'pending', + capturedAt: null, + capturedBy: null, + verifiedAt: null, + oid: '', + formToken: '', + leftCount: 0, + goodsTitle: '', + }, + binding: { + prepareStatus: 'pending', + cloudSourceKeys, + resolvedSourceKey: resolvedCloudSourceKey, + skuId: primaryDeliveryItem.cloudSkuId, + skuName: primaryDeliveryItem.cloudSkuName, + vnKey: '1', + vnId: 0, + vnPhone: '', + bindUrl: '', + bindPreparedAt: null, + bindExpiresAt: null, + bindProbeAt: null, + bindProbeStatus: '', + bindProbeMessage: '', + }, + role: { + status: 'pending', + name: '', + rid: '', + refreshedAt: null, + errorMessage: '', + rawInfo: null, + }, + purchase: { + autoBuyEnabled: cloudtentaclesConfig.autoBuyEnabled !== false, + minAssetReserve: Number(cloudtentaclesConfig.minAssetReserve || 0) || 0, + usedKnapsack: false, + purchaseTriggered: false, + assetBefore: 0, + assetAfter: 0, + purchaseAt: null, + }, + dispatch: { + status: 'pending', + dispatchAt: null, + dispatchBy: null, + sendType: 0, + note: '', + }, + returnNumber: { + status: 'pending', + returnedAt: null, + returnedBy: null, + autoReturnEnabled: cloudtentaclesConfig.autoReturnNumberAfterDispatch === true, + }, + consume: { + status: 'pending', + shopId: String( + kuaishouConsumeConfig.shopId || + kuaishouShopConfig.shopId || + itemSnapshot.shopId || + order.shop_id || + '', + ).trim(), + shopName: String( + kuaishouConsumeConfig.shopName || + kuaishouShopConfig.kshopName || + itemSnapshot.shopName || + order.shop_name || + '', + ).trim(), + autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true, + consumedAt: null, + errorMessage: '', + }, + notes: String(fulfillmentConfig.notes || '').trim(), + } + : null, + kuaishouFeifei: isKuaishouFeifeiExecutor(executorKey) + ? { + flowType: 'kuaishou_feifei', + productCode: String(kuaishouFeifeiConfig.productCode || '').trim(), + productName: String(kuaishouFeifeiConfig.productName || item.sku_name || '').trim(), + platformOrderNo: '', + orderNo: '', + rechargeStatus: 0, + rechargeStatusLabel: '', + rechargeResultMessage: '', + claimUrl: '', + consumeStatus: 'pending', + h5: { + entryUrl: '', + rechargeUrl: '', + }, + lastSyncedAt: null, + } + : null, + } +} + +async function resolveDynamicFulfillmentProfile( + item: OrderItemRow, + getProfileByKey: FulfillmentProfileResolver, +): Promise { + return ( + await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) || + await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey) + ) +} + +async function resolveDynamicCloudtentaclesProfile( + item: OrderItemRow, + getProfileByKey: FulfillmentProfileResolver, +): Promise { + const snapshot = parseJsonObject(item.item_snapshot_json) + const cloudtentacles = parseJsonObject(snapshot.cloudtentacles) + const cloudSourceKeys = normalizeStringArray(cloudtentacles.cloudSourceKeys) + const deliveryItems = normalizeCloudDeliveryItems({ + deliveryItems: cloudtentacles.deliveryItems, + skuId: cloudtentacles.cloudSkuId, + skuName: cloudtentacles.cloudSkuName || item.sku_name, + }) + const primaryDeliveryItem = deliveryItems[0] || { + cloudSkuId: 0, + cloudSkuName: '', + quantity: 1, + } + + if ( + !primaryDeliveryItem.cloudSkuId || + !primaryDeliveryItem.cloudSkuName || + cloudSourceKeys.length === 0 + ) { + return null + } + + const profile = await getProfileByKey(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) + if (!profile) { + return null + } + + return { + ...profile, + profile_id: Number(profile.id || profile.profile_id || 0), + profile_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, + profile_name: String(profile.profile_name || profile.name || '快手 cloud 履约').trim(), + executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, + requires_claim: false, + auto_dispatch: false, + config_json: { + flowType: 'kuaishou_cloud_fulfillment', + configId: `${String(cloudtentacles.matchMode || 'cloudtentacles_name').trim()}:${String(cloudtentacles.normalizedProductName || primaryDeliveryItem.cloudSkuId).trim()}`, + cloudtentacles: { + cloudSourceKeys, + skuId: primaryDeliveryItem.cloudSkuId, + skuName: primaryDeliveryItem.cloudSkuName, + resolvedSourceKey: + cloudSourceKeys.length === 1 + ? String(cloudtentacles.resolvedSourceKey || cloudSourceKeys[0] || '').trim() + : '', + deliveryItems, + vnKey: '1', + autoBuyEnabled: true, + minAssetReserve: 0, + autoReturnNumberAfterDispatch: true, + }, + kuaishouConsume: { + shopId: String(snapshot.shopId || '').trim(), + shopName: String(snapshot.shopName || '').trim(), + autoConsumeAfterDispatch: false, + }, + notes: + cloudtentacles.matchMode === 'cloudtentacles_override' + ? '91卡券商品名命中 cloudtentacles 覆盖规则' + : '91卡券商品名自动匹配 cloudtentacles 商品', + }, + } +} + +async function resolveDynamicKuaishouFeifeiProfile( + item: OrderItemRow, + getProfileByKey: FulfillmentProfileResolver, +): Promise { + const snapshot = parseJsonObject(item.item_snapshot_json) + const feifei = parseJsonObject(snapshot.kuaishouFeifei) + const productCode = String(feifei.productCode || '').trim() + if (!productCode) { + return null + } + + const profile = await getProfileByKey(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI) + if (!profile) { + return null + } + + return { + ...profile, + profile_id: Number(profile.id || profile.profile_id || 0), + profile_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, + profile_name: String(profile.profile_name || profile.name || 'kuaishou-feifei 履约').trim(), + executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, + requires_claim: true, + auto_dispatch: false, + config_json: { + flowType: 'kuaishou_feifei', + configId: `kuaishou_feifei:${productCode}`, + kuaishouFeifei: { + productCode, + productName: String(feifei.skuName || feifei.productName || item.sku_name || '').trim(), + matchMode: String(feifei.matchMode || 'kuaishou_feifei_name').trim(), + }, + notes: '91卡券商品名自动匹配 kuaishou-feifei 商品映射', + }, + } +} + +function parseJsonObject(value: unknown): JsonObject { + if (!value) { + return {} + } + + if (typeof value === 'object' && !Array.isArray(value)) { + return value as JsonObject + } + + try { + const parsed = JSON.parse(String(value || '{}')) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as JsonObject) + : {} + } catch { + return {} + } +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) +} + +function normalizeCloudDeliveryItems(value: JsonObject): Array<{ + cloudSkuId: number + cloudSkuName: string + quantity: number +}> { + const rawItems = Array.isArray(value.deliveryItems) ? value.deliveryItems : [] + const items = rawItems + .map((item) => normalizeCloudDeliveryItem(item)) + .filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } => + Boolean(item), + ) + + if (items.length > 0) { + return mergeCloudDeliveryItems(items) + } + + const cloudSkuId = Number(value.skuId || 0) || 0 + if (!cloudSkuId) { + return [] + } + + return [ + { + cloudSkuId, + cloudSkuName: String(value.skuName || '').trim(), + quantity: 1, + }, + ] +} + +function normalizeCloudDeliveryItem(value: unknown) { + const source = + value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { + return null + } + + const quantity = Number(source.quantity || 1) || 1 + return { + cloudSkuId, + cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), + quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, + } +} + +function mergeCloudDeliveryItems( + items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, +) { + const merged = new Map() + + for (const item of items) { + const existing = merged.get(item.cloudSkuId) + if (existing) { + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + continue + } + + merged.set(item.cloudSkuId, { ...item }) + } + + return Array.from(merged.values()) +} diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts new file mode 100644 index 00000000..4ff24aad --- /dev/null +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -0,0 +1,236 @@ +import { + normalizeCloudtentaclesMatchName, + resolveCloudtentaclesSkuByProductName, + type CloudtentaclesNameMatchResult, +} from '../order/cloudtentacles-name-match-service.js' +import { + resolveKuaishouFeifeiProductByName, + type KuaishouFeifeiProductMatch, +} from '../platforms/kuaishou-feifei/product-rule-service.js' + +export type FulfillmentItem = { + itemId?: string + externalItemId?: string + skuCode?: string + skuName?: string + externalSkuCode?: string + externalSkuName?: string + quantity?: number + spec?: Record + snapshot?: Record + [key: string]: unknown +} + +type ResolveOrderItemForFulfillmentInput = { + provider?: string + platform?: string + item?: FulfillmentItem +} + +type HasConfiguredOrderItemsInput = Omit & { + items?: FulfillmentItem[] +} + +type FulfillmentItemCandidate = { + externalItemId: string + externalSkuCode: string + externalSkuName: string + externalSkuNameNormalized: string + resolvedSkuCode: string + cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null + kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null + isConfigured: boolean +} + +export type ResolvedFulfillmentItem = FulfillmentItem & { + itemId: string + externalItemId: string + externalSkuCode: string + externalSkuName: string + skuCode: string + skuName: string + snapshot: Record + isConfigured: boolean +} + +export async function resolveOrderItemForFulfillment({ + provider = '', + platform = '', + item = {}, +}: ResolveOrderItemForFulfillmentInput): Promise { + const candidate = await resolveConfiguredItemCandidate({ + provider, + platform, + item, + }) + const { + externalItemId, + externalSkuCode, + externalSkuName, + externalSkuNameNormalized, + cloudtentaclesNameMatch, + kuaishouFeifeiMatch, + } = candidate + + const resolvedSkuCode = pickFirstNonEmpty([ + cloudtentaclesNameMatch?.cloudSkuName, + kuaishouFeifeiMatch?.productCode, + externalSkuCode, + externalItemId, + ]) + const resolvedSkuName = pickFirstNonEmpty([ + cloudtentaclesNameMatch?.cloudSkuName, + kuaishouFeifeiMatch?.skuName, + item.skuName, + externalSkuName, + resolvedSkuCode, + ]) + const snapshot = { + ...(isPlainObject(item.snapshot) ? item.snapshot : {}), + externalItemId, + externalSkuCode, + externalSkuName, + externalSkuNameNormalized, + resolvedSkuCode, + matchMode: cloudtentaclesNameMatch?.matchMode || '', + cloudtentacles: cloudtentaclesNameMatch + ? { + matchMode: cloudtentaclesNameMatch.matchMode, + productName: cloudtentaclesNameMatch.productName, + normalizedProductName: cloudtentaclesNameMatch.normalizedProductName, + cloudSkuId: cloudtentaclesNameMatch.cloudSkuId, + cloudSkuName: cloudtentaclesNameMatch.cloudSkuName, + cloudSkuPrice: cloudtentaclesNameMatch.cloudSkuPrice, + cloudSkuInventory: cloudtentaclesNameMatch.cloudSkuInventory, + cloudSourceKeys: cloudtentaclesNameMatch.cloudSourceKeys, + resolvedSourceKey: cloudtentaclesNameMatch.resolvedSourceKey, + deliveryItems: cloudtentaclesNameMatch.deliveryItems, + skuSnapshot: cloudtentaclesNameMatch.skuSnapshot, + } + : null, + kuaishouFeifei: kuaishouFeifeiMatch + ? { + matchMode: kuaishouFeifeiMatch.matchMode, + productName: kuaishouFeifeiMatch.productName, + normalizedProductName: kuaishouFeifeiMatch.normalizedProductName, + productCode: kuaishouFeifeiMatch.productCode, + skuName: kuaishouFeifeiMatch.skuName, + } + : null, + isConfigured: candidate.isConfigured, + } + + return { + ...item, + itemId: externalItemId, + externalItemId, + externalSkuCode, + externalSkuName, + skuCode: resolvedSkuCode, + skuName: resolvedSkuName, + snapshot, + isConfigured: candidate.isConfigured, + } +} + +export async function hasConfiguredOrderItems({ + provider = '', + platform = '', + items = [], +}: HasConfiguredOrderItemsInput): Promise { + const candidates = await Promise.all( + (Array.isArray(items) ? items : []).map((item) => resolveConfiguredItemCandidate({ + provider, + platform, + item, + })), + ) + + return candidates.some((item) => item.isConfigured) +} + +export function normalizeProductName(value: unknown): string { + return normalizeCloudtentaclesMatchName(value) +} + +function pickFirstNonEmpty(values: unknown[]): string { + for (const value of values) { + const normalized = String(value || '').trim() + if (normalized) { + return normalized + } + } + + return '' +} + +async function resolveConfiguredItemCandidate({ + provider = '', + platform = '', + item = {}, +}: ResolveOrderItemForFulfillmentInput): Promise { + const externalItemId = pickFirstNonEmpty([ + item.externalItemId, + item.itemId, + ]) + const externalSkuCode = pickFirstNonEmpty([ + item.externalSkuCode, + item.skuCode, + externalItemId, + ]) + const externalSkuName = pickFirstNonEmpty([ + item.externalSkuName, + item.skuName, + externalSkuCode, + ]) + const externalSkuNameNormalized = normalizeProductName(externalSkuName) + + if (isOpen91KuaishouOrder(provider, platform)) { + const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName) + const kuaishouFeifeiMatch = cloudtentaclesNameMatch + ? null + : resolveKuaishouFeifeiProductByName(externalSkuName) + const resolvedSkuCode = pickFirstNonEmpty([ + cloudtentaclesNameMatch?.cloudSkuName, + kuaishouFeifeiMatch?.productCode, + externalSkuCode, + externalItemId, + ]) + + return { + externalItemId, + externalSkuCode, + externalSkuName, + externalSkuNameNormalized, + resolvedSkuCode, + cloudtentaclesNameMatch, + kuaishouFeifeiMatch, + isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch), + } + } + + const resolvedSkuCode = pickFirstNonEmpty([ + externalSkuCode, + externalItemId, + ]) + + return { + externalItemId, + externalSkuCode, + externalSkuName, + externalSkuNameNormalized, + resolvedSkuCode, + cloudtentaclesNameMatch: null, + kuaishouFeifeiMatch: null, + isConfigured: false, + } +} + +function isOpen91KuaishouOrder(provider: unknown, platform: unknown) { + return String(provider || '').trim() === '91kaquan' && + String(platform || '').trim() === 'kuaishou' +} + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index 11879185..3fee453b 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -1,12 +1,7 @@ import { findLatestOrderByPlatformOrderId } from '../../repositories/order-repo.js' import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js' import { listTasksByOrderId } from '../../repositories/task-repo.js' -import { buildClaimUrl } from '../claim/claim-service.js' -import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud/index.js' -import { - ensureKuaishouFeifeiClaimShortLink, - resolveKuaishouFeifeiClaimUrl, -} from '../fulfillment/kuaishou-feifei/index.js' +import { resolveTaskDeliveryLink } from '../fulfillment/delivery-link-service.js' import { logIntegration } from '../../utils/logger.js' import { OPEN_91_MANUAL_FAILED_STATUS, @@ -107,28 +102,8 @@ export async function queryOpen91Order(payload: JsonObject = {}, { requestId = ' const industryReadyTaskIds = new Set(industryVoucherState.readyTaskIds) for (const task of tasks) { - let claimUrl = '' - let expireTime: unknown = task.primary_claim_expires_at || task.claim_expires_at || '' - const primaryToken = String(task.primary_claim_token || task.claim_token || '').trim() - - if (String(task.executor_key || '').trim() === 'kuaishou_feifei') { - const context = parseJsonObject(task.context_json) - claimUrl = await ensureKuaishouFeifeiClaimShortLink(task) - if (!claimUrl) { - claimUrl = resolveKuaishouFeifeiClaimUrl(context.kuaishouFeifei) - } - expireTime = '' - } else if (primaryToken) { - claimUrl = buildClaimUrl(primaryToken) - } else if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') { - try { - const ensured = await ensureTaskClaimLink(task) - claimUrl = String(ensured.claimUrl || '').trim() - expireTime = ensured.expiredAt || expireTime - } catch { - claimUrl = '' - } - } + const deliveryLink = await resolveTaskDeliveryLink(task) + const claimUrl = String(deliveryLink?.claimUrl || '').trim() if (!claimUrl) { continue @@ -141,7 +116,7 @@ export async function queryOpen91Order(payload: JsonObject = {}, { requestId = ' readyTaskIds.push(Number(task.id)) cardItems.push(buildOpen91CardItem({ claimUrl, - expireTime, + expireTime: deliveryLink?.expireTime || '', })) } diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index 55d92dd5..bc631d3e 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -2,38 +2,26 @@ import { createTask, listTasksByOrderId, updateTask } from '../../repositories/t import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js' import { createTaskClaimToken } from '../claim/claim-service.js' import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js' -import { prepareKuaishouFeifeiTask } from '../fulfillment/kuaishou-feifei/index.js' +import { + planFulfillmentTaskForOrderItem, + type FulfillmentBindingLike, +} from '../fulfillment/planner.js' +import { preparePaidFulfillmentTask } from '../fulfillment/executors/registry.js' +import type { FulfillmentPrepareDeps } from '../fulfillment/executors/types.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' import { TASK_STATUS, - isPaidPreparationStableStatus, resolveInitialPaidTaskStatus, - shouldEnsureKuaishouCloudClaimLink, } from '../../domain/task-status.js' import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' -type JsonObject = Record - type ClaimTokenLike = { token: string expired_at: string [key: string]: unknown } -type FulfillmentBindingLike = { - id: number - profile_id?: number - profile_key?: string - profile_name?: string - name?: string - executor_key?: string - requires_claim?: boolean - auto_dispatch?: boolean - config_json?: string | JsonObject - [key: string]: unknown -} - type DeliveryTaskRow = TaskRow & { skuCode?: string skuName?: string @@ -54,17 +42,6 @@ type DeliveryTaskDeps = { randomId?: (prefix?: string) => string } -type RuntimeDeliveryTaskDeps = Required< - Pick< - DeliveryTaskDeps, - 'updateTask' | 'createTaskClaimToken' | 'notifyTaskAutoManualReview' | 'nowIso' - > -> - -type TaskContext = { - [key: string]: unknown -} - export async function syncDeliveryTasksForOrder( order: OrderRow, orderItems: OrderItemRow[], @@ -88,7 +65,7 @@ export async function syncDeliveryTasksForOrderWithDeps( randomId: createRandomId = randomId, } = deps - const runtimeDeps: RuntimeDeliveryTaskDeps = { + const runtimeDeps: FulfillmentPrepareDeps = { updateTask: updateDeliveryTask, createTaskClaimToken: createClaimToken, notifyTaskAutoManualReview: notifyManualReview, @@ -102,53 +79,30 @@ export async function syncDeliveryTasksForOrderWithDeps( return existingTasks } - const itemMap = new Map(orderItems.map((item) => [item.id, item])) - const preparedTasks = await Promise.all( - existingTasks.map((task) => - preparePaidTask( - { - ...task, - skuCode: itemMap.get(task.order_item_id)?.sku_code || '', - skuName: itemMap.get(task.order_item_id)?.sku_name || '', - }, - runtimeDeps, - ), - ), - ) - return preparedTasks.filter(isTaskRow) + return preparePaidTasks(existingTasks, runtimeDeps) } const tasks: DeliveryTaskRow[] = [] for (const item of orderItems) { - const profile = await resolveDynamicFulfillmentProfile(item, getProfileByKey) + const plan = await planFulfillmentTaskForOrderItem({ + order, + item, + getProfileByKey, + }) - if (!profile) { + if (!plan) { continue } - const itemSnapshot = parseJsonObject(item.item_snapshot_json) + const quantity = Math.max(1, Number(item.quantity || 1)) - const fulfillmentConfig = parseJsonObject(profile.config_json) - const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) - const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei) - const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume) - const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop) - const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys) - const resolvedCloudSourceKey = - cloudSourceKeys.length === 1 - ? String(cloudtentaclesConfig.resolvedSourceKey || cloudSourceKeys[0] || '').trim() - : '' - const deliveryItems = normalizeCloudDeliveryItems(cloudtentaclesConfig) - const primaryDeliveryItem = deliveryItems[0] || { - cloudSkuId: Number(cloudtentaclesConfig.skuId || 0) || 0, - cloudSkuName: String(cloudtentaclesConfig.skuName || '').trim(), - quantity: 1, - } for (let index = 0; index < quantity; index += 1) { const createdAt = getNowIso() const initialStatus = - order.pay_status === 'paid' ? resolvePaidTaskStatus(profile) : TASK_STATUS.PENDING_PAYMENT + order.pay_status === 'paid' + ? resolvePaidTaskStatus(plan.profile) + : TASK_STATUS.PENDING_PAYMENT const task = await createDeliveryTask({ orderId: order.id, @@ -160,131 +114,20 @@ export async function syncDeliveryTasksForOrderWithDeps( shopName: order.shop_name, platformOrderId: order.platform_order_id, taskNo: createRandomId('DT'), - profileId: Number(profile.profile_id || profile.id), - executorKey: String(profile.executor_key || 'manual_dispatch'), + profileId: plan.profileId, + executorKey: plan.executorKey, taskStatus: initialStatus, deliveryStatus: 'pending', resultCode: '', resultMessage: '', claimToken: '', claimExpiresAt: null, - automationMode: profile.auto_dispatch ? 'automatic' : 'manual', - requiresClaim: Boolean(profile.requires_claim), - userActionStatus: profile.requires_claim ? 'pending_claim' : 'not_required', + automationMode: plan.autoDispatch ? 'automatic' : 'manual', + requiresClaim: plan.requiresClaim, + userActionStatus: plan.requiresClaim ? 'pending_claim' : 'not_required', attemptCount: 0, lastError: '', - contextJson: JSON.stringify({ - profileKey: String(profile.profile_key || ''), - profileName: String(profile.profile_name || profile.name || ''), - skuCode: item.sku_code, - skuName: item.sku_name, - kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key) - ? { - flowType: 'kuaishou_cloud_fulfillment', - configId: String(fulfillmentConfig.configId || '').trim(), - internalSkuCode: item.sku_code, - internalSkuName: item.sku_name, - deliveryItems, - ticket: { - code: '', - status: 'pending', - capturedAt: null, - capturedBy: null, - verifiedAt: null, - oid: '', - formToken: '', - leftCount: 0, - goodsTitle: '', - }, - binding: { - prepareStatus: 'pending', - cloudSourceKeys, - resolvedSourceKey: resolvedCloudSourceKey, - skuId: primaryDeliveryItem.cloudSkuId, - skuName: primaryDeliveryItem.cloudSkuName, - vnKey: '1', - vnId: 0, - vnPhone: '', - bindUrl: '', - bindPreparedAt: null, - bindExpiresAt: null, - bindProbeAt: null, - bindProbeStatus: '', - bindProbeMessage: '', - }, - role: { - status: 'pending', - name: '', - rid: '', - refreshedAt: null, - errorMessage: '', - rawInfo: null, - }, - purchase: { - autoBuyEnabled: cloudtentaclesConfig.autoBuyEnabled !== false, - minAssetReserve: Number(cloudtentaclesConfig.minAssetReserve || 0) || 0, - usedKnapsack: false, - purchaseTriggered: false, - assetBefore: 0, - assetAfter: 0, - purchaseAt: null, - }, - dispatch: { - status: 'pending', - dispatchAt: null, - dispatchBy: null, - sendType: 0, - note: '', - }, - returnNumber: { - status: 'pending', - returnedAt: null, - returnedBy: null, - autoReturnEnabled: cloudtentaclesConfig.autoReturnNumberAfterDispatch === true, - }, - consume: { - status: 'pending', - shopId: String( - kuaishouConsumeConfig.shopId || - kuaishouShopConfig.shopId || - itemSnapshot.shopId || - order.shop_id || - '', - ).trim(), - shopName: String( - kuaishouConsumeConfig.shopName || - kuaishouShopConfig.kshopName || - itemSnapshot.shopName || - order.shop_name || - '', - ).trim(), - autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true, - consumedAt: null, - errorMessage: '', - }, - notes: String(fulfillmentConfig.notes || '').trim(), - } - : null, - kuaishouFeifei: isKuaishouFeifeiExecutor(profile.executor_key) - ? { - flowType: 'kuaishou_feifei', - productCode: String(kuaishouFeifeiConfig.productCode || '').trim(), - productName: String(kuaishouFeifeiConfig.productName || item.sku_name || '').trim(), - platformOrderNo: '', - orderNo: '', - rechargeStatus: 0, - rechargeStatusLabel: '', - rechargeResultMessage: '', - claimUrl: '', - consumeStatus: 'pending', - h5: { - entryUrl: '', - rechargeUrl: '', - }, - lastSyncedAt: null, - } - : null, - }), + contextJson: JSON.stringify(plan.context), createdAt, updatedAt: createdAt, }) @@ -303,336 +146,26 @@ export async function syncDeliveryTasksForOrderWithDeps( return tasks } - const preparedTasks = await Promise.all(tasks.map((task) => preparePaidTask(task, runtimeDeps))) + const preparedTasks = await Promise.all( + tasks.map((task) => preparePaidFulfillmentTask(task, runtimeDeps)), + ) return preparedTasks.filter(isTaskRow) } -async function preparePaidTask( - task: DeliveryTaskRow, - deps: Partial = {}, -): Promise { - const { - updateTask: updateDeliveryTask = updateTask, - createTaskClaimToken: createClaimToken = createTaskClaimToken, - notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview, - nowIso: getNowIso = nowIso, - } = deps - - const now = getNowIso() - - if (isPaidPreparationStableStatus(task.task_status)) { - return task - } - - if (isKuaishouCloudExecutor(task.executor_key)) { - if (shouldEnsureKuaishouCloudClaimLink(task.task_status)) { - if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) { - const claimToken = await createClaimToken(task.id) - return updateDeliveryTask(task.id, { - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - user_action_status: 'pending_claim', - updated_at: now, - }) - } - - return task - } - - const claimToken = await createClaimToken(task.id) - - return updateDeliveryTask(task.id, { - task_status: TASK_STATUS.PENDING_BINDING_PREPARE, - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - user_action_status: 'pending_claim', - last_error: task.last_error || '领取链接已生成,等待客户提交核销码', - updated_at: now, - }) - } - - if (isKuaishouFeifeiExecutor(task.executor_key)) { - try { - return await prepareKuaishouFeifeiTask(task as TaskRow) - } catch (error) { - const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败' - const updatedTask = await updateDeliveryTask(task.id, { - task_status: TASK_STATUS.MANUAL_REVIEW, - user_action_status: 'not_required', - last_error: message, - result_code: 'kuaishou_feifei_prepare_failed', - result_message: message, - updated_at: now, - }) - await notifyManualReview({ - task: updatedTask || task, - reason: message, - source: 'kuaishou_feifei_prepare_failed', - }) - return updatedTask - } - } - - if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') { - const lastError = task.last_error || '当前任务需要人工履约处理' - const updatedTask = await updateDeliveryTask(task.id, { - task_status: TASK_STATUS.MANUAL_REVIEW, - user_action_status: 'not_required', - last_error: lastError, - updated_at: now, - }) - await notifyManualReview({ - task: updatedTask || task, - reason: lastError, - source: 'manual_dispatch_profile', - }) - return updatedTask - } - - return task +async function preparePaidTasks( + tasks: TaskRow[], + deps: FulfillmentPrepareDeps, +) { + const preparedTasks = await Promise.all( + tasks.map((task) => preparePaidFulfillmentTask(task, deps)), + ) + return preparedTasks.filter(isTaskRow) } function resolvePaidTaskStatus(profile: FulfillmentBindingLike | null | undefined): string { return resolveInitialPaidTaskStatus(profile) } -function parseTaskContext(task: { context_json?: unknown } | null | undefined): TaskContext { - const value = task?.context_json - - if (!value) { - return {} - } - - if (typeof value === 'object') { - return value as TaskContext - } - - try { - const parsed = JSON.parse(String(value || '{}')) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as TaskContext) - : {} - } catch { - return {} - } -} - -function parseJsonObject(value: unknown): JsonObject { - if (!value) { - return {} - } - - if (typeof value === 'object' && !Array.isArray(value)) { - return value as JsonObject - } - - try { - const parsed = JSON.parse(String(value || '{}')) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as JsonObject) - : {} - } catch { - return {} - } -} - -async function resolveDynamicCloudtentaclesProfile( - item: OrderItemRow, - getProfileByKey: (profileKey: string) => Promise, -): Promise { - const snapshot = parseJsonObject(item.item_snapshot_json) - const cloudtentacles = parseJsonObject(snapshot.cloudtentacles) - const cloudSourceKeys = normalizeStringArray(cloudtentacles.cloudSourceKeys) - const deliveryItems = normalizeCloudDeliveryItems({ - deliveryItems: cloudtentacles.deliveryItems, - skuId: cloudtentacles.cloudSkuId, - skuName: cloudtentacles.cloudSkuName || item.sku_name, - }) - const primaryDeliveryItem = deliveryItems[0] || { - cloudSkuId: 0, - cloudSkuName: '', - quantity: 1, - } - - if ( - !primaryDeliveryItem.cloudSkuId || - !primaryDeliveryItem.cloudSkuName || - cloudSourceKeys.length === 0 - ) { - return null - } - - const profile = await getProfileByKey('kuaishou_ct_assisted') - if (!profile) { - return null - } - - return { - ...profile, - profile_id: Number(profile.id || profile.profile_id || 0), - profile_key: 'kuaishou_ct_assisted', - profile_name: String(profile.profile_name || profile.name || '快手 cloud 履约').trim(), - executor_key: 'kuaishou_ct_assisted', - requires_claim: false, - auto_dispatch: false, - config_json: { - flowType: 'kuaishou_cloud_fulfillment', - configId: `${String(cloudtentacles.matchMode || 'cloudtentacles_name').trim()}:${String(cloudtentacles.normalizedProductName || primaryDeliveryItem.cloudSkuId).trim()}`, - cloudtentacles: { - cloudSourceKeys, - skuId: primaryDeliveryItem.cloudSkuId, - skuName: primaryDeliveryItem.cloudSkuName, - resolvedSourceKey: - cloudSourceKeys.length === 1 - ? String(cloudtentacles.resolvedSourceKey || cloudSourceKeys[0] || '').trim() - : '', - deliveryItems, - vnKey: '1', - autoBuyEnabled: true, - minAssetReserve: 0, - autoReturnNumberAfterDispatch: true, - }, - kuaishouConsume: { - shopId: String(snapshot.shopId || '').trim(), - shopName: String(snapshot.shopName || '').trim(), - autoConsumeAfterDispatch: false, - }, - notes: - cloudtentacles.matchMode === 'cloudtentacles_override' - ? '91卡券商品名命中 cloudtentacles 覆盖规则' - : '91卡券商品名自动匹配 cloudtentacles 商品', - }, - } -} - -async function resolveDynamicFulfillmentProfile( - item: OrderItemRow, - getProfileByKey: (profileKey: string) => Promise, -): Promise { - return ( - await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) || - await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey) - ) -} - -async function resolveDynamicKuaishouFeifeiProfile( - item: OrderItemRow, - getProfileByKey: (profileKey: string) => Promise, -): Promise { - const snapshot = parseJsonObject(item.item_snapshot_json) - const feifei = parseJsonObject(snapshot.kuaishouFeifei) - const productCode = String(feifei.productCode || '').trim() - if (!productCode) { - return null - } - - const profile = await getProfileByKey('kuaishou_feifei') - if (!profile) { - return null - } - - return { - ...profile, - profile_id: Number(profile.id || profile.profile_id || 0), - profile_key: 'kuaishou_feifei', - profile_name: String(profile.profile_name || profile.name || 'kuaishou-feifei 履约').trim(), - executor_key: 'kuaishou_feifei', - requires_claim: true, - auto_dispatch: false, - config_json: { - flowType: 'kuaishou_feifei', - configId: `kuaishou_feifei:${productCode}`, - kuaishouFeifei: { - productCode, - productName: String(feifei.skuName || feifei.productName || item.sku_name || '').trim(), - matchMode: String(feifei.matchMode || 'kuaishou_feifei_name').trim(), - }, - notes: '91卡券商品名自动匹配 kuaishou-feifei 商品映射', - }, - } -} - -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - - return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) -} - -function normalizeCloudDeliveryItems(value: JsonObject): Array<{ - cloudSkuId: number - cloudSkuName: string - quantity: number -}> { - const rawItems = Array.isArray(value.deliveryItems) ? value.deliveryItems : [] - const items = rawItems - .map((item) => normalizeCloudDeliveryItem(item)) - .filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } => - Boolean(item), - ) - - if (items.length > 0) { - return mergeCloudDeliveryItems(items) - } - - const cloudSkuId = Number(value.skuId || 0) || 0 - if (!cloudSkuId) { - return [] - } - - return [ - { - cloudSkuId, - cloudSkuName: String(value.skuName || '').trim(), - quantity: 1, - }, - ] -} - -function normalizeCloudDeliveryItem(value: unknown) { - const source = - value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} - const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 - if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { - return null - } - - const quantity = Number(source.quantity || 1) || 1 - return { - cloudSkuId, - cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), - quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, - } -} - -function mergeCloudDeliveryItems( - items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, -) { - const merged = new Map() - - for (const item of items) { - const existing = merged.get(item.cloudSkuId) - if (existing) { - existing.quantity += item.quantity - existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName - continue - } - - merged.set(item.cloudSkuId, { ...item }) - } - - return Array.from(merged.values()) -} - function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is TaskRow { return Boolean(task && Number(task.id || 0) > 0) } - -function isKuaishouCloudExecutor(value: unknown): boolean { - return String(value || '').trim() === 'kuaishou_ct_assisted' -} - -function isKuaishouFeifeiExecutor(value: unknown): boolean { - return String(value || '').trim() === 'kuaishou_feifei' -} diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index 5f67196c..44e96a42 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -4,14 +4,12 @@ import { updateOrder, } from '../../repositories/order-repo.js' import { replaceOrderItems } from '../../repositories/order-item-repo.js' -import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js' import { syncDeliveryTasksForOrder } from './delivery-task-service.js' -import { resolveOrderItemForFulfillment } from './product-match-service.js' -import { bindKuaishouIndustryVouchersToOrderTasks } from '../platforms/kuaishou-industry/voucher-binding-service.js' +import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js' import { - isKuaishouIndustryVoucherSendCallbackSuccess, - resolveKuaishouIndustryVoucherSendCallbackMessage, -} from '../platforms/kuaishou-industry/voucher-service.js' + resolveOrderFulfillmentReadiness, + syncOrderFulfillmentAttachments, +} from '../fulfillment/order-fulfillment-readiness-service.js' import { nowIso } from '../../utils/time.js' import { logIntegration } from '../../utils/logger.js' import { createHttpError } from '../../utils/http.js' @@ -189,19 +187,19 @@ export async function upsertOrderFromSource( })), ) - const industryVoucherGate = await resolveKuaishouIndustryVoucherGate(order.platform_order_id) - if (!industryVoucherGate.allowed) { + const readiness = await resolveOrderFulfillmentReadiness(order) + if (!readiness.allowed) { logIntegration('[order-service]', `${sourceLabel} 订单暂停履约:电子凭证发码回调未确认`, { orderId: order.id, provider: order.provider, platform: order.platform, platformOrderId: order.platform_order_id, - voucherCount: industryVoucherGate.voucherCount, - reason: industryVoucherGate.reason, + voucherCount: readiness.voucherCount, + reason: readiness.reason, }, { level: 'warn' }) return { - ignoreReason: 'kuaishou_industry_send_callback_unconfirmed', + ignoreReason: readiness.blockReason || 'fulfillment_not_ready', order, orderItems, tasks: [], @@ -209,7 +207,7 @@ export async function upsertOrderFromSource( } const tasks = await syncDeliveryTasksForOrder(order, orderItems) - await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, { + await syncOrderFulfillmentAttachments(order, tasks, { source: `${sourceLabel}_order_upsert`, now, }) @@ -233,36 +231,6 @@ export async function upsertOrderFromSource( } } -async function resolveKuaishouIndustryVoucherGate(platformOrderId: unknown) { - const oid = String(platformOrderId || '').trim() - if (!oid) { - return { - allowed: true, - voucherCount: 0, - reason: '', - } - } - - const vouchers = await listKuaishouIndustryVouchersByOid(oid) - if (vouchers.length === 0 || vouchers.every(isKuaishouIndustryVoucherSendCallbackSuccess)) { - return { - allowed: true, - voucherCount: vouchers.length, - reason: '', - } - } - - const blockedVoucher = vouchers.find((voucher) => !isKuaishouIndustryVoucherSendCallbackSuccess(voucher)) - - return { - allowed: false, - voucherCount: vouchers.length, - reason: blockedVoucher - ? resolveKuaishouIndustryVoucherSendCallbackMessage(blockedVoucher) - : '电子凭证发码回调未确认', - } -} - const ORDER_STATUS_PRIORITY = { created: 0, paid: 1, diff --git a/apps/backend/src/services/order/product-match-service.ts b/apps/backend/src/services/order/product-match-service.ts index f67c9572..1209d1ee 100644 --- a/apps/backend/src/services/order/product-match-service.ts +++ b/apps/backend/src/services/order/product-match-service.ts @@ -1,236 +1,9 @@ -import { - normalizeCloudtentaclesMatchName, - resolveCloudtentaclesSkuByProductName, - type CloudtentaclesNameMatchResult, -} from './cloudtentacles-name-match-service.js' -import { - resolveKuaishouFeifeiProductByName, - type KuaishouFeifeiProductMatch, -} from '../platforms/kuaishou-feifei/product-rule-service.js' - -export type FulfillmentItem = { - itemId?: string - externalItemId?: string - skuCode?: string - skuName?: string - externalSkuCode?: string - externalSkuName?: string - quantity?: number - spec?: Record - snapshot?: Record - [key: string]: unknown -} - -type ResolveOrderItemForFulfillmentInput = { - provider?: string - platform?: string - item?: FulfillmentItem -} - -type HasConfiguredOrderItemsInput = Omit & { - items?: FulfillmentItem[] -} - -type FulfillmentItemCandidate = { - externalItemId: string - externalSkuCode: string - externalSkuName: string - externalSkuNameNormalized: string - resolvedSkuCode: string - cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null - kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null - isConfigured: boolean -} - -export type ResolvedFulfillmentItem = FulfillmentItem & { - itemId: string - externalItemId: string - externalSkuCode: string - externalSkuName: string - skuCode: string - skuName: string - snapshot: Record - isConfigured: boolean -} - -export async function resolveOrderItemForFulfillment({ - provider = '', - platform = '', - item = {}, -}: ResolveOrderItemForFulfillmentInput): Promise { - const candidate = await resolveConfiguredItemCandidate({ - provider, - platform, - item, - }) - const { - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, - cloudtentaclesNameMatch, - kuaishouFeifeiMatch, - } = candidate - - const resolvedSkuCode = pickFirstNonEmpty([ - cloudtentaclesNameMatch?.cloudSkuName, - kuaishouFeifeiMatch?.productCode, - externalSkuCode, - externalItemId, - ]) - const resolvedSkuName = pickFirstNonEmpty([ - cloudtentaclesNameMatch?.cloudSkuName, - kuaishouFeifeiMatch?.skuName, - item.skuName, - externalSkuName, - resolvedSkuCode, - ]) - const snapshot = { - ...(isPlainObject(item.snapshot) ? item.snapshot : {}), - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, - resolvedSkuCode, - matchMode: cloudtentaclesNameMatch?.matchMode || '', - cloudtentacles: cloudtentaclesNameMatch - ? { - matchMode: cloudtentaclesNameMatch.matchMode, - productName: cloudtentaclesNameMatch.productName, - normalizedProductName: cloudtentaclesNameMatch.normalizedProductName, - cloudSkuId: cloudtentaclesNameMatch.cloudSkuId, - cloudSkuName: cloudtentaclesNameMatch.cloudSkuName, - cloudSkuPrice: cloudtentaclesNameMatch.cloudSkuPrice, - cloudSkuInventory: cloudtentaclesNameMatch.cloudSkuInventory, - cloudSourceKeys: cloudtentaclesNameMatch.cloudSourceKeys, - resolvedSourceKey: cloudtentaclesNameMatch.resolvedSourceKey, - deliveryItems: cloudtentaclesNameMatch.deliveryItems, - skuSnapshot: cloudtentaclesNameMatch.skuSnapshot, - } - : null, - kuaishouFeifei: kuaishouFeifeiMatch - ? { - matchMode: kuaishouFeifeiMatch.matchMode, - productName: kuaishouFeifeiMatch.productName, - normalizedProductName: kuaishouFeifeiMatch.normalizedProductName, - productCode: kuaishouFeifeiMatch.productCode, - skuName: kuaishouFeifeiMatch.skuName, - } - : null, - isConfigured: candidate.isConfigured, - } - - return { - ...item, - itemId: externalItemId, - externalItemId, - externalSkuCode, - externalSkuName, - skuCode: resolvedSkuCode, - skuName: resolvedSkuName, - snapshot, - isConfigured: candidate.isConfigured, - } -} - -export async function hasConfiguredOrderItems({ - provider = '', - platform = '', - items = [], -}: HasConfiguredOrderItemsInput): Promise { - const candidates = await Promise.all( - (Array.isArray(items) ? items : []).map((item) => resolveConfiguredItemCandidate({ - provider, - platform, - item, - })), - ) - - return candidates.some((item) => item.isConfigured) -} - -export function normalizeProductName(value: unknown): string { - return normalizeCloudtentaclesMatchName(value) -} - -function pickFirstNonEmpty(values: unknown[]): string { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -} - -async function resolveConfiguredItemCandidate({ - provider = '', - platform = '', - item = {}, -}: ResolveOrderItemForFulfillmentInput): Promise { - const externalItemId = pickFirstNonEmpty([ - item.externalItemId, - item.itemId, - ]) - const externalSkuCode = pickFirstNonEmpty([ - item.externalSkuCode, - item.skuCode, - externalItemId, - ]) - const externalSkuName = pickFirstNonEmpty([ - item.externalSkuName, - item.skuName, - externalSkuCode, - ]) - const externalSkuNameNormalized = normalizeProductName(externalSkuName) - - if (isOpen91KuaishouOrder(provider, platform)) { - const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName) - const kuaishouFeifeiMatch = cloudtentaclesNameMatch - ? null - : resolveKuaishouFeifeiProductByName(externalSkuName) - const resolvedSkuCode = pickFirstNonEmpty([ - cloudtentaclesNameMatch?.cloudSkuName, - kuaishouFeifeiMatch?.productCode, - externalSkuCode, - externalItemId, - ]) - - return { - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, - resolvedSkuCode, - cloudtentaclesNameMatch, - kuaishouFeifeiMatch, - isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch), - } - } - - const resolvedSkuCode = pickFirstNonEmpty([ - externalSkuCode, - externalItemId, - ]) - - return { - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, - resolvedSkuCode, - cloudtentaclesNameMatch: null, - kuaishouFeifeiMatch: null, - isConfigured: false, - } -} - -function isOpen91KuaishouOrder(provider: unknown, platform: unknown) { - return String(provider || '').trim() === '91kaquan' - && String(platform || '').trim() === 'kuaishou' -} - -function isPlainObject(value: unknown): value is Record { - return Object.prototype.toString.call(value) === '[object Object]' -} +export { + hasConfiguredOrderItems, + normalizeProductName, + resolveOrderItemForFulfillment, +} from '../fulfillment/product-resolution-service.js' +export type { + FulfillmentItem, + ResolvedFulfillmentItem, +} from '../fulfillment/product-resolution-service.js'