Merge branch 'main' into dev
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { buildClaimUrl, buildConfiguredClaimUrl } from '../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl } from '../claim/claim-service.js'
|
||||
import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js'
|
||||
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
|
||||
import {
|
||||
@@ -385,9 +385,7 @@ export async function getAdminTaskDetail(
|
||||
const taskContext = parseTaskContext(task)
|
||||
const taskState = parseTaskState(task)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
// 后台回显的领取链接口径按**订单来源**决定(不再按 executor_key):
|
||||
// 抖音码速达订单用后台配置的 `claimLinkBase`(买家领取页,走 affiliate_dash 领取流程);
|
||||
// 其它来源沿用全局 buildClaimUrl(orders.claimBaseUrl)。
|
||||
// 后台回显的领取链接统一为 `/h5/<平台订单号>`;抖音码速达仍可使用自己的领取站基址。
|
||||
const claimUrl = resolveAdminTaskClaimUrl(claimToken, order)
|
||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
||||
const cloudSourceLabelMap = buildCloudSourceLabelMap()
|
||||
@@ -514,32 +512,24 @@ export async function getAdminTaskDetail(
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台展示的领取链接。
|
||||
* 后台展示的买家领取链接,所有来源统一为 `/h5/<平台订单号>`。
|
||||
*
|
||||
* - 抖音码速达订单(`provider='douyin_mashida'`):`claimLinkBase` + token(买家领取页,
|
||||
* 走 affiliate_dash 领取流程);未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。
|
||||
* - 其它来源:沿用全局 `buildClaimUrl`(orders.claimBaseUrl),历史行为不变。
|
||||
*
|
||||
* ⚠️ 这里按 **provider** 判断,不按 executor_key:抖音皮肤单已改走 `affiliate_dash` 通道,
|
||||
* 按执行器判断会失效。拿不到 provider(order 为空)时按「其它来源」处理。
|
||||
* 抖音码速达可继续使用自己配置的领取站基址,其它来源使用全局 claimBaseUrl;
|
||||
* 订单号缺失时不生成链接。链接不再依赖 claim token。
|
||||
*/
|
||||
function resolveAdminTaskClaimUrl(
|
||||
claimToken: { token: string } | null | undefined,
|
||||
order: Pick<OrderRow, 'provider'> | null | undefined,
|
||||
order: Pick<OrderRow, 'provider' | 'platform_order_id'> | null | undefined,
|
||||
): string {
|
||||
const token = String(claimToken?.token || '').trim()
|
||||
if (!token) {
|
||||
if (!String(claimToken?.token || '').trim() || !order) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (isDouyinMashidaProvider(order?.provider)) {
|
||||
const claimUrl = buildConfiguredClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token)
|
||||
if (claimUrl) {
|
||||
return claimUrl
|
||||
}
|
||||
}
|
||||
const douyinClaimBase = isDouyinMashidaProvider(order.provider)
|
||||
? getDouyinMashidaSourceConfig().claimLinkBase
|
||||
: ''
|
||||
|
||||
return buildClaimUrl(token)
|
||||
return buildOrderClaimUrl(order.platform_order_id, douyinClaimBase || undefined)
|
||||
}
|
||||
|
||||
function mapKuaishouIndustryVoucherContext(value: unknown): JsonRecord | null {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
cancelCertificateVerify,
|
||||
ensureCertificateForOrderItem,
|
||||
formatGrantTime,
|
||||
resolveDouyinMashidaCertLink,
|
||||
verifyCertificate,
|
||||
} from '../redeem/certificate-service.js'
|
||||
import type {
|
||||
@@ -154,7 +155,18 @@ export async function syncAdminDouyinMashidaCertificate(
|
||||
})
|
||||
}
|
||||
|
||||
const certLink = String(certificate.cert_link || config.certLink || '').trim()
|
||||
const context = await resolveCertificateOrderContext({
|
||||
...(certificate.local_order_id !== null ? { localOrderId: certificate.local_order_id } : {}),
|
||||
...(certificate.order_item_id !== null ? { orderItemId: certificate.order_item_id } : {}),
|
||||
})
|
||||
const regeneratedCertLink = await resolveDouyinMashidaCertLink({
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
config,
|
||||
})
|
||||
const certLink = String(
|
||||
regeneratedCertLink || certificate.cert_link || config.certLink || '',
|
||||
).trim()
|
||||
if (!certLink) {
|
||||
throw createHttpError('未配置兑换链接(certLink),无法同步券码', {
|
||||
statusCode: 400,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../utils/masking.js'
|
||||
|
||||
import type { TaskRow } from '../../types/repository/rows.js'
|
||||
@@ -52,11 +52,11 @@ export async function ensureTaskClaimLink(task: TaskRow): Promise<TaskClaimLink>
|
||||
return {
|
||||
token,
|
||||
expiredAt,
|
||||
claimUrl: buildClaimUrl(token),
|
||||
claimUrl: buildOrderClaimUrl(task.platform_order_id),
|
||||
}
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const claimToken = await createTaskClaimToken(task.id, task.platform_order_id)
|
||||
return {
|
||||
token: claimToken.token,
|
||||
expiredAt: claimToken.expired_at,
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { getOrderById } from '../../../repositories/order-repo.js'
|
||||
import {
|
||||
createTaskClaimToken,
|
||||
buildClaimUrl,
|
||||
buildConfiguredClaimUrl,
|
||||
} from '../../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js'
|
||||
import { getDouyinMashidaSourceConfig } from '../../platforms/douyin-mashida/source-config-service.js'
|
||||
import { isDouyinMashidaProvider } from '../../platforms/douyin-mashida/order-mapper.js'
|
||||
import { notifyTaskAutoManualReview } from '../../notification/domain-notifications.js'
|
||||
@@ -240,33 +236,25 @@ export async function retryAdminTask(
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台回显的领取链接口径(按**订单来源**判断,不按 executor_key):
|
||||
* - 抖音码速达订单:后台配置的 `claimLinkBase` + token(买家领取页,走 affiliate_dash 领取流程);
|
||||
* 未配 `claimLinkBase` 时退回通用领取页,至少链接不会为空;
|
||||
* - 其它渠道:沿用 runtimeConfig 的通用领取页(行为完全不变)。
|
||||
*/
|
||||
/** 后台回显的买家领取链接统一为 `/h5/<平台订单号>`,不再生成 token 链接。 */
|
||||
async function resolveAdminTaskClaimUrl(
|
||||
task: Pick<TaskRow, 'order_id'>,
|
||||
token: string,
|
||||
): Promise<string> {
|
||||
const normalizedToken = String(token || '').trim()
|
||||
if (!normalizedToken) {
|
||||
if (!String(token || '').trim()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const order = await getOrderById(task.order_id)
|
||||
if (isDouyinMashidaProvider(order?.provider)) {
|
||||
const claimUrl = buildConfiguredClaimUrl(
|
||||
getDouyinMashidaSourceConfig().claimLinkBase,
|
||||
normalizedToken,
|
||||
)
|
||||
if (claimUrl) {
|
||||
return claimUrl
|
||||
}
|
||||
if (!order) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return buildClaimUrl(normalizedToken)
|
||||
const douyinClaimBase = isDouyinMashidaProvider(order.provider)
|
||||
? getDouyinMashidaSourceConfig().claimLinkBase
|
||||
: ''
|
||||
|
||||
return buildOrderClaimUrl(order.platform_order_id, douyinClaimBase || undefined)
|
||||
}
|
||||
|
||||
export async function regenerateAdminTaskClaimLink(
|
||||
@@ -306,7 +294,7 @@ export async function regenerateAdminTaskClaimLink(
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const claimToken = await createTaskClaimToken(task.id, task.platform_order_id)
|
||||
const nextStatus =
|
||||
task.task_status === TASK_STATUS.MANUAL_REVIEW || task.task_status === TASK_STATUS.RETRY_PENDING
|
||||
? TASK_STATUS.LINK_GENERATED
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildConfiguredClaimUrl,
|
||||
buildOrderClaimUrl,
|
||||
pickOrderItemClaimTask,
|
||||
resolveClaimTokenExpiration,
|
||||
} from './claim-service.js'
|
||||
@@ -18,26 +18,40 @@ test('领取链接保留正整数小时的限时配置能力', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('配置化领取链接只拼传入基址(去尾斜杠),不读全局 claimBaseUrl', () => {
|
||||
test('所有来源统一生成 /h5/<订单号> 领取链接', () => {
|
||||
const orderNo = '6919156612984680391'
|
||||
|
||||
assert.equal(
|
||||
buildConfiguredClaimUrl('https://x.ngrok-free.app/#/claim', 'AbC123'),
|
||||
'https://x.ngrok-free.app/#/claim/AbC123',
|
||||
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/#/claim'),
|
||||
`https://x.ngrok-free.app/h5/${orderNo}`,
|
||||
)
|
||||
assert.equal(
|
||||
buildConfiguredClaimUrl('https://x.ngrok-free.app/claim/', 'AbC123'),
|
||||
'https://x.ngrok-free.app/claim/AbC123',
|
||||
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/claim/'),
|
||||
`https://x.ngrok-free.app/h5/${orderNo}`,
|
||||
)
|
||||
assert.equal(
|
||||
buildConfiguredClaimUrl('https://x.ngrok-free.app', 'AbC123'),
|
||||
'https://x.ngrok-free.app/AbC123',
|
||||
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/custom'),
|
||||
`https://x.ngrok-free.app/custom/h5/${orderNo}`,
|
||||
)
|
||||
assert.equal(
|
||||
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/h5///'),
|
||||
`https://x.ngrok-free.app/h5/${orderNo}`,
|
||||
)
|
||||
assert.equal(
|
||||
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/h5/old-token'),
|
||||
`https://x.ngrok-free.app/h5/${orderNo}`,
|
||||
)
|
||||
assert.equal(
|
||||
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/claim/old-token'),
|
||||
`https://x.ngrok-free.app/h5/${orderNo}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('配置化领取链接基址/token 缺失返回空串(由调用方决定不发链接、不发消息)', () => {
|
||||
assert.equal(buildConfiguredClaimUrl('', 'AbC123'), '')
|
||||
assert.equal(buildConfiguredClaimUrl(undefined, 'AbC123'), '')
|
||||
assert.equal(buildConfiguredClaimUrl('https://x/claim', ''), '')
|
||||
assert.equal(buildConfiguredClaimUrl('https://x/claim', null), '')
|
||||
test('订单号或领取基址缺失/无效时返回空串', () => {
|
||||
assert.equal(buildOrderClaimUrl('', 'https://x/claim'), '')
|
||||
assert.equal(buildOrderClaimUrl(null, 'https://x/claim'), '')
|
||||
assert.equal(buildOrderClaimUrl('6919156612984680391', ''), '')
|
||||
assert.equal(buildOrderClaimUrl('6919156612984680391', 'not-a-url'), '')
|
||||
})
|
||||
|
||||
test('pickOrderItemClaimTask 取该订单项 task id 最小且带 token 的单元', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createClaimToken } from '../../repositories/claim-token-repo.js'
|
||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import { getTaskById, listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import { addHours, nowIso } from '../../utils/time.js'
|
||||
@@ -11,7 +11,10 @@ type TaskClaimToken = NonNullable<Awaited<ReturnType<typeof createClaimToken>>>
|
||||
claimUrl: string
|
||||
}
|
||||
|
||||
export async function createTaskClaimToken(taskId: number | string): Promise<TaskClaimToken> {
|
||||
export async function createTaskClaimToken(
|
||||
taskId: number | string,
|
||||
orderNo?: unknown,
|
||||
): Promise<TaskClaimToken> {
|
||||
const createdAt = nowIso()
|
||||
const expiredAt = resolveClaimTokenExpiration(createdAt, runtimeConfig.orders.tokenTtlHours)
|
||||
const token = await createClaimToken({
|
||||
@@ -33,9 +36,12 @@ export async function createTaskClaimToken(taskId: number | string): Promise<Tas
|
||||
})
|
||||
}
|
||||
|
||||
const resolvedOrderNo =
|
||||
orderNo === undefined ? (await getTaskById(Number(taskId)))?.platform_order_id : orderNo
|
||||
|
||||
return {
|
||||
...token,
|
||||
claimUrl: buildClaimUrl(token.token),
|
||||
claimUrl: buildOrderClaimUrl(resolvedOrderNo),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,36 +55,63 @@ export function resolveClaimTokenExpiration(
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局默认领取链接:`runtimeConfig.orders.claimBaseUrl` + `/` + token。
|
||||
* 所有来源统一的买家领取链接:`<领取站>/h5/<平台订单号>`。
|
||||
*
|
||||
* ⚠️ 默认基址是主站 HashRouter 里的买家领取页(`…/#/claim`)。**抖音码速达**的领取链接
|
||||
* 走 `buildConfiguredClaimUrl` + 后台配置的 `claimLinkBase`,两条口径刻意分开、互不回退。
|
||||
* `claimBaseUrl` 兼容历史配置形态(如 `https://x/#/claim`、`https://x/claim`),
|
||||
* 这里会归一化到同一站点的 `/h5` 路径;订单号为空或基址无效时返回空串。
|
||||
*/
|
||||
export function buildClaimUrl(token: string): string {
|
||||
const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim()
|
||||
return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token
|
||||
}
|
||||
|
||||
/**
|
||||
* 按**调用方给定的基址**拼领取链接:`${基址去尾斜杠}/${token}`。
|
||||
*
|
||||
* 抖音码速达的基址只能来自后台配置的 `claimLinkBase`(买家领取页),
|
||||
* 绝不回退 `buildClaimUrl` 的 `orders.claimBaseUrl`(那是主站默认领取页,口径不同)。
|
||||
* 基址或 token 缺失时返回空串,由调用方决定「不生成链接 / 不发消息」,不猜、不回退。
|
||||
*/
|
||||
export function buildConfiguredClaimUrl(baseUrl: unknown, token: unknown): string {
|
||||
const base = String(baseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
const normalizedToken = String(token || '').trim()
|
||||
if (!base || !normalizedToken) {
|
||||
export function buildOrderClaimUrl(
|
||||
orderNo: unknown,
|
||||
claimBaseUrl: unknown = runtimeConfig.orders.claimBaseUrl,
|
||||
): string {
|
||||
const normalizedOrderNo = String(orderNo ?? '').trim()
|
||||
if (!normalizedOrderNo) {
|
||||
return ''
|
||||
}
|
||||
return `${base}/${normalizedToken}`
|
||||
|
||||
const baseUrl = resolveH5ClaimBaseUrl(claimBaseUrl)
|
||||
if (!baseUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `${baseUrl}/${encodeURIComponent(normalizedOrderNo)}`
|
||||
}
|
||||
|
||||
/** 把任意历史领取页基址归一化成站点下的 `/h5` 基址。 */
|
||||
export function resolveH5ClaimBaseUrl(claimBaseUrl: unknown): string {
|
||||
const rawValue = String(claimBaseUrl ?? '').trim()
|
||||
if (!rawValue) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(rawValue)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
const pathname = url.pathname.replace(/\/+$/, '')
|
||||
const h5Match = pathname.match(/\/h5(?:\/|$)/i)
|
||||
const claimMatch = pathname.match(/\/claim(?:\/|$)/i)
|
||||
|
||||
if (h5Match?.index !== undefined) {
|
||||
url.pathname = `${pathname.slice(0, h5Match.index)}/h5`
|
||||
} else if (claimMatch?.index !== undefined) {
|
||||
url.pathname = `${pathname.slice(0, claimMatch.index)}/h5`
|
||||
} else if (pathname && pathname !== '/') {
|
||||
url.pathname = `${pathname}/h5`
|
||||
} else {
|
||||
url.pathname = '/h5'
|
||||
}
|
||||
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export type OrderItemClaimLink = {
|
||||
/** 领取链接;未配置 claimLinkBase、或该订单项没有带 token 的履约任务时为空串 */
|
||||
/** 统一 H5 领取链接;无可用履约单元或订单号时为空串 */
|
||||
claimUrl: string
|
||||
/** 命中的履约任务 id(多个时取最小);无可用任务时不下发该字段 */
|
||||
taskId?: number
|
||||
@@ -97,7 +130,7 @@ export type OrderItemClaimTaskPick = {
|
||||
token: string
|
||||
/** 该订单项下的履约任务总数,不论有没有 token */
|
||||
taskCount: number
|
||||
/** 其中带 claim token 的单元数(>1 时会有多个领取链接) */
|
||||
/** 其中带 claim token 的单元数;对外仍共用一个订单号链接 */
|
||||
tokenTaskCount: number
|
||||
}
|
||||
|
||||
@@ -105,7 +138,7 @@ export type OrderItemClaimTaskPick = {
|
||||
* 纯函数:从订单的履约任务里挑出该订单项的领取单元。
|
||||
*
|
||||
* 一个订单项按数量会拆成多个履约单元(`fulfillment_tasks`),每个单元有自己的 claim token;
|
||||
* 这里固定取 **task id 最小** 的那个,保证同一张券每次解析出同一条链接。
|
||||
* 这里固定取 **task id 最小** 的那个,用于判断该订单项是否已经准备好自动履约。
|
||||
*/
|
||||
export function pickOrderItemClaimTask(
|
||||
tasks: Array<Partial<TaskRow>> | null | undefined,
|
||||
@@ -142,17 +175,17 @@ export function pickOrderItemClaimTask(
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用领取链接解析(不再依赖任何具体执行器):
|
||||
* 某订单项履约任务上的 claim token → `claimLinkBase` + `/` + token。
|
||||
* 通用领取单元解析(不再依赖任何具体执行器):
|
||||
* 先按订单项找到带 claim token 的履约任务,再生成统一的 `/h5/<订单号>` 链接。
|
||||
*
|
||||
* 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道,履约任务上的 token 就是买家领取 token,
|
||||
* 因此这里只按 `order_item_id` 找任务、**不看 executor_key**:
|
||||
* 换执行器不会再让领取链接解析失效。
|
||||
* 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道。这里只按 `order_item_id` 找任务、
|
||||
* **不看 executor_key**,换执行器不会影响链接和自动发货判断。
|
||||
*/
|
||||
export async function resolveOrderItemClaimLink(input: {
|
||||
orderId: number | string
|
||||
orderNo: unknown
|
||||
orderItemId: number | string
|
||||
claimLinkBase: unknown
|
||||
claimLinkBase?: unknown
|
||||
}): Promise<OrderItemClaimLink> {
|
||||
const orderId = Number(input.orderId)
|
||||
if (!Number.isFinite(orderId)) {
|
||||
@@ -180,7 +213,7 @@ export async function resolveOrderItemClaimLink(input: {
|
||||
}
|
||||
|
||||
return {
|
||||
claimUrl: buildConfiguredClaimUrl(input.claimLinkBase, picked.token),
|
||||
claimUrl: buildOrderClaimUrl(input.orderNo, input.claimLinkBase || undefined),
|
||||
taskId: picked.taskId,
|
||||
taskCount: picked.taskCount,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js'
|
||||
import { buildClaimUrl } from './claim-service.js'
|
||||
import { buildOrderClaimUrl } from './claim-service.js'
|
||||
import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js'
|
||||
import { buildClaimIdentityPayload } from './claim-identity.js'
|
||||
import { normalizeAffiliateDashFlow } from '../fulfillment/affiliate-dash/index.js'
|
||||
@@ -301,7 +301,7 @@ function buildAffiliateDashClaimDetailPayload({
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
claimUrl: buildOrderClaimUrl(order.platform_order_id),
|
||||
flowType: 'affiliate_dash' as const,
|
||||
channelOffline: false,
|
||||
channelOfflineMessage: '',
|
||||
@@ -382,7 +382,7 @@ function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }:
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
claimUrl: buildOrderClaimUrl(order.platform_order_id),
|
||||
flowType:
|
||||
executorKey === 'kuaishou_feifei'
|
||||
? ('kuaishou_feifei' as const)
|
||||
@@ -436,7 +436,7 @@ function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }:
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
claimUrl: buildOrderClaimUrl(order.platform_order_id),
|
||||
flowType: String(task.executor_key || 'manual_dispatch').trim() || 'manual_dispatch',
|
||||
channelOffline: false,
|
||||
channelOfflineMessage: '',
|
||||
|
||||
@@ -178,6 +178,7 @@ export async function backfillAdminAffiliateDashSkus() {
|
||||
) AS sku_code
|
||||
FROM fulfillment_tasks ft
|
||||
WHERE ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
AND ft.created_at >= NOW() - INTERVAL '30 days'
|
||||
AND btrim(COALESCE(ft.context_json->>'skuCode', '')) <> ''
|
||||
ORDER BY ft.order_item_id, ft.created_at DESC, ft.id DESC
|
||||
@@ -202,6 +203,7 @@ export async function backfillAdminAffiliateDashSkus() {
|
||||
SELECT COUNT(DISTINCT ft.order_item_id)::int
|
||||
FROM fulfillment_tasks ft
|
||||
WHERE ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
AND ft.created_at >= NOW() - INTERVAL '30 days'
|
||||
) AS affiliate_item_count,
|
||||
(SELECT COUNT(*)::int FROM target) AS matched_count,
|
||||
@@ -245,6 +247,7 @@ export async function backfillAdminAffiliateDashSkus() {
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id
|
||||
AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
AND ft.created_at >= NOW() - INTERVAL '30 days'
|
||||
)
|
||||
`,
|
||||
|
||||
@@ -34,12 +34,27 @@ export function productSourceOf(canonicalSku: string): string {
|
||||
}
|
||||
|
||||
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
|
||||
export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string {
|
||||
export function productSourceFilterSql(
|
||||
paramIndex: number,
|
||||
column = 'p.canonical_sku',
|
||||
nameColumn = 'p.canonical_name',
|
||||
): string {
|
||||
return `(
|
||||
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
|
||||
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
|
||||
OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%')
|
||||
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
|
||||
OR (
|
||||
$${paramIndex} = 'manual'
|
||||
AND ${column} LIKE 'man:%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM internal_sku_channels live_channel
|
||||
WHERE live_channel.enabled = TRUE
|
||||
AND live_channel.is_fallback = FALSE
|
||||
AND live_channel.channel <> 'manual'
|
||||
AND BTRIM(live_channel.internal_sku) = BTRIM(${nameColumn})
|
||||
)
|
||||
)
|
||||
)`
|
||||
}
|
||||
|
||||
@@ -112,6 +127,16 @@ export function mapProduct(row: ProductRow) {
|
||||
activeCostEffectiveFrom: row.active_cost_effective_from
|
||||
? String(row.active_cost_effective_from)
|
||||
: null,
|
||||
templatePricingMode: row.template_pricing_mode || null,
|
||||
templateEnabled:
|
||||
row.template_enabled === null || row.template_enabled === undefined
|
||||
? null
|
||||
: row.template_enabled === true,
|
||||
templateSharingEnabled:
|
||||
row.template_sharing_enabled === null || row.template_sharing_enabled === undefined
|
||||
? null
|
||||
: row.template_sharing_enabled === true,
|
||||
activePrimaryChannel: row.active_primary_channel || null,
|
||||
affiliateTaskCount: Number(row.affiliate_task_count || 0),
|
||||
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
|
||||
createdAt: row.created_at,
|
||||
@@ -163,13 +188,11 @@ export function mapCostVersion(row: CostVersionRow) {
|
||||
const revenueFen = Number(row.revenue_fen || 0)
|
||||
const rewardAvgFen = Number(row.reward_avg_fen || 0)
|
||||
const rewardFen = Number(row.reward_fen || 0)
|
||||
const ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0)
|
||||
const ruleRewardAmountFen = Number(row.rule_reward_amount || 0)
|
||||
const amountKind =
|
||||
row.cost_item_code === 'sys_work_order_reward' &&
|
||||
row.scope_type === 'rule' &&
|
||||
ruleUnitPriceFen > 0 &&
|
||||
ruleRewardAmountFen === 0
|
||||
row.work_product_rule_pricing_mode === 'unit'
|
||||
? 'unit_price'
|
||||
: 'fixed'
|
||||
return {
|
||||
@@ -189,7 +212,7 @@ export function mapCostVersion(row: CostVersionRow) {
|
||||
workProductRuleName: row.work_product_rule_name || '',
|
||||
workProductRuleKey: row.work_product_rule_key || '',
|
||||
ruleRewardAmountFen,
|
||||
ruleUnitPriceFen,
|
||||
ruleUnitPriceFen: Number(row.rule_unit_price_fen || 0),
|
||||
revenueItemCount,
|
||||
revenueFen,
|
||||
revenueAvgFen: revenueItemCount > 0 ? Math.round(revenueFen / revenueItemCount) : 0,
|
||||
|
||||
@@ -42,7 +42,18 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
||||
OR ($1 = 'affiliate_dash' AND p.canonical_sku LIKE 'aff:%')
|
||||
OR ($1 = 'work_order' AND (p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%'))
|
||||
OR ($1 = 'third_party_voucher' AND p.canonical_sku LIKE 'tpv:%')
|
||||
OR ($1 = 'manual' AND p.canonical_sku LIKE 'man:%')
|
||||
OR (
|
||||
$1 = 'manual'
|
||||
AND p.canonical_sku LIKE 'man:%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM internal_sku_channels live_channel
|
||||
WHERE live_channel.enabled = TRUE
|
||||
AND live_channel.is_fallback = FALSE
|
||||
AND live_channel.channel <> 'manual'
|
||||
AND BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
scoped_cost_models AS (
|
||||
@@ -78,12 +89,11 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
WHERE ce.status = 'open'
|
||||
AND (
|
||||
$1::text IS NULL
|
||||
OR ce.order_item_id IN (
|
||||
SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1
|
||||
)
|
||||
OR COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1
|
||||
)
|
||||
) AS open_exception_count,
|
||||
(
|
||||
@@ -99,11 +109,21 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
||||
)
|
||||
const row = result.rows[0]
|
||||
const [productSourceResult, costModelResult] = await Promise.all([
|
||||
query<{ canonical_sku: string; total: number }>(
|
||||
`SELECT canonical_sku, COUNT(*)::int AS total
|
||||
FROM product_catalog
|
||||
WHERE status = 'active'
|
||||
GROUP BY canonical_sku`,
|
||||
query<{ canonical_sku: string; active_primary_channel: string | null; total: number }>(
|
||||
`SELECT p.canonical_sku,
|
||||
(
|
||||
SELECT c.channel
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled = TRUE
|
||||
AND c.is_fallback = FALSE
|
||||
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
|
||||
ORDER BY c.priority, c.channel
|
||||
LIMIT 1
|
||||
) AS active_primary_channel,
|
||||
COUNT(*)::int AS total
|
||||
FROM product_catalog p
|
||||
WHERE p.status = 'active'
|
||||
GROUP BY p.canonical_sku, p.canonical_name`,
|
||||
),
|
||||
query<{ cost_model: string; total: number }>(
|
||||
`SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`,
|
||||
@@ -137,7 +157,9 @@ function normalizeScope(raw: unknown): CostCenterScope | null {
|
||||
}
|
||||
|
||||
/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */
|
||||
function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: number }>) {
|
||||
function productSourceCountsOf(
|
||||
rows: Array<{ canonical_sku: string; active_primary_channel: string | null; total: number }>,
|
||||
) {
|
||||
const counts: Record<string, number> = {
|
||||
affiliate_dash: 0,
|
||||
work_order: 0,
|
||||
@@ -146,6 +168,14 @@ function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: numbe
|
||||
}
|
||||
for (const row of rows) {
|
||||
const source = productSourceOf(row.canonical_sku)
|
||||
// man: 只是旧来源;已有其他主渠道的商品不再计入当前人工客服。
|
||||
if (
|
||||
source === 'manual' &&
|
||||
row.active_primary_channel &&
|
||||
row.active_primary_channel !== 'manual'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const key = source || 'uncategorized'
|
||||
counts[key] = (counts[key] || 0) + toNumber(row.total)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ export type ProductRow = {
|
||||
cost_version_count?: number
|
||||
active_cost_amount_fen?: number | null
|
||||
active_cost_effective_from?: string | null
|
||||
template_pricing_mode?: string | null
|
||||
template_enabled?: boolean | null
|
||||
template_sharing_enabled?: boolean | null
|
||||
active_primary_channel?: string | null
|
||||
affiliate_task_count?: number
|
||||
affiliate_last_fulfilled_at?: string | null
|
||||
}
|
||||
@@ -109,6 +113,7 @@ export type CostVersionRow = {
|
||||
work_product_rule_id: number | null
|
||||
work_product_rule_name: string | null
|
||||
work_product_rule_key: string | null
|
||||
work_product_rule_pricing_mode?: 'fixed' | 'unit' | null
|
||||
rule_reward_amount: number | null
|
||||
rule_unit_price_fen: number | null
|
||||
revenue_item_count: number | null
|
||||
|
||||
@@ -275,21 +275,13 @@ test('mapping lookup follows specificity and concrete-shop priority', () => {
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 2026-10-29 生产:改「接单模板」商品价格 500。
|
||||
*
|
||||
* PostgreSQL 报 `inconsistent types deduced for parameter $1`(integer versus bigint):
|
||||
* 同一个 $1 在 `reward_amount = $1` 处按 bigint 推断,在 `CASE ... THEN $1 ELSE 0`
|
||||
* 处又因为 `0` 是 integer 被推断成 integer。node-pg 不发送参数类型,必须显式转换。
|
||||
*/
|
||||
test('接单模板改价 SQL 的参数必须显式指定 bigint', async () => {
|
||||
test('接单模板改价必须在同一事务中同步成本版本与模板投影', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const source = await readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8')
|
||||
assert.ok(
|
||||
source.includes(
|
||||
'unit_price_fen = CASE WHEN unit_price_fen > 0 THEN $1::bigint ELSE 0::bigint END',
|
||||
),
|
||||
)
|
||||
assert.ok(source.includes('const costVersionId = await withTransaction'))
|
||||
assert.ok(source.includes('reward_amount = $1::bigint'))
|
||||
assert.ok(source.includes('unit_price_fen = $2::bigint'))
|
||||
assert.ok(source.includes('cost_work_order_future_price_not_supported'))
|
||||
})
|
||||
|
||||
test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
|
||||
@@ -299,6 +291,23 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
|
||||
assert.ok(productSourceFilterSql(1).includes("'tpv:%'"))
|
||||
})
|
||||
|
||||
test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => {
|
||||
const sql = productSourceFilterSql(1)
|
||||
assert.ok(sql.includes(`$1 = 'manual'`))
|
||||
assert.ok(sql.includes("live_channel.channel <> 'manual'"))
|
||||
assert.ok(sql.includes('BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)'))
|
||||
})
|
||||
|
||||
test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const source = await readFile(
|
||||
new URL('./cost-center-overview-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('JOIN order_items oi ON oi.id = ce.order_item_id'))
|
||||
assert.ok(!source.includes('ce.order_item_id IN ('))
|
||||
})
|
||||
|
||||
test('tpv: 商品来源归为第三方卡券', async () => {
|
||||
const { productSourceOf } = await import('./cost-center-mappers.js')
|
||||
const assert = (await import('node:assert/strict')).default
|
||||
|
||||
@@ -324,6 +324,7 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
|
||||
p.canonical_name,
|
||||
wpr.product_name AS work_product_rule_name,
|
||||
wpr.rule_key AS work_product_rule_key,
|
||||
wpr.pricing_mode AS work_product_rule_pricing_mode,
|
||||
wpr.reward_amount AS rule_reward_amount,
|
||||
wpr.unit_price_fen AS rule_unit_price_fen,
|
||||
rev.revenue_item_count,
|
||||
|
||||
@@ -103,6 +103,25 @@ test('成本解析:卡券渠道按件计,取业务日期前的最新版本',
|
||||
assert.equal(resolution.unitAmountFen, 5000)
|
||||
})
|
||||
|
||||
test('成本解析:接单模板按 SKU 计件时,预估成本按数量乘每件金额', () => {
|
||||
const resolution = resolveCost(
|
||||
item({
|
||||
fulfillment_channel: 'work_order',
|
||||
canonical_product_id: null,
|
||||
quantity: 2,
|
||||
work_product_rule_id: 9,
|
||||
work_product_rule_pricing_mode: 'unit',
|
||||
}),
|
||||
costItems,
|
||||
versions,
|
||||
pricingModes,
|
||||
)
|
||||
assert.ok(resolution)
|
||||
assert.equal(resolution.costVersionId, 101)
|
||||
assert.equal(resolution.unitAmountFen, 840)
|
||||
assert.equal(resolution.totalAmountFen, 1680)
|
||||
})
|
||||
|
||||
test('成本解析:生效日期晚于业务日期的版本不参与', () => {
|
||||
const version = selectCostVersion(versions, {
|
||||
costItemId: 4,
|
||||
|
||||
@@ -62,6 +62,7 @@ export type CostCandidateItem = {
|
||||
basis_at: string
|
||||
work_order_id: number | null
|
||||
work_product_rule_id: number | null
|
||||
work_product_rule_pricing_mode?: 'fixed' | 'unit' | null
|
||||
work_order_status: string | null
|
||||
reward_amount: number | null
|
||||
revenue_fen: number
|
||||
@@ -157,7 +158,14 @@ export function resolveCost(
|
||||
})
|
||||
if (!version) return null
|
||||
|
||||
const pricingMode = pricingModeByItemId.get(costItem.id) || costItem.pricing_mode
|
||||
const pricingMode =
|
||||
item.fulfillment_channel === 'work_order' &&
|
||||
costItem.code === 'sys_work_order_reward' &&
|
||||
item.work_product_rule_pricing_mode
|
||||
? item.work_product_rule_pricing_mode === 'unit'
|
||||
? 'per_item'
|
||||
: 'per_order'
|
||||
: pricingModeByItemId.get(costItem.id) || costItem.pricing_mode
|
||||
const amount = Number(version.amount_fen) || 0
|
||||
const quantity = Math.max(1, Number(item.quantity) || 1)
|
||||
|
||||
@@ -306,11 +314,13 @@ const CANDIDATE_SQL = `
|
||||
), 0)::bigint AS refund_fen,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
wpr.pricing_mode AS work_product_rule_pricing_mode,
|
||||
wo.status AS work_order_status,
|
||||
wo.reward_amount
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IN (
|
||||
'affiliate_dash', 'third_party_voucher', 'work_order', 'manual'
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/** 旧人工归一商品清理集成测试(真实 PostgreSQL)。 */
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const TEST_DATABASE_URL = String(
|
||||
process.env.LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL || '',
|
||||
).trim()
|
||||
|
||||
test(
|
||||
'已被其他主渠道接管的历史 man 商品只归档并修正兜底指向',
|
||||
{ skip: !TEST_DATABASE_URL ? '未设置 LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL' : false },
|
||||
async () => {
|
||||
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||
const { closeDb, query } = await import('../../db/client.js')
|
||||
const { archiveSupersededManualProducts } = await import('./product-catalog-service.js')
|
||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||
const internalSku = `清理测试人工商品-${suffix}`
|
||||
const manSku = `man:${internalSku}`
|
||||
const liveSku = `aff:cleanup-${suffix}`
|
||||
let manProductId = 0
|
||||
let liveProductId = 0
|
||||
|
||||
try {
|
||||
const inserted = await query<{ id: number; canonical_sku: string }>(
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES
|
||||
($1, $3, '人工客服', '件', 'active', '{"source":"manual_normalization"}'::jsonb, NOW(), NOW()),
|
||||
($2, $3, '自动发货', '件', 'active', '{}'::jsonb, NOW(), NOW())
|
||||
RETURNING id, canonical_sku
|
||||
`,
|
||||
[manSku, liveSku, internalSku],
|
||||
)
|
||||
manProductId = Number(inserted.rows.find((row) => row.canonical_sku === manSku)?.id)
|
||||
liveProductId = Number(inserted.rows.find((row) => row.canonical_sku === liveSku)?.id)
|
||||
assert.ok(manProductId > 0)
|
||||
assert.ok(liveProductId > 0)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled,
|
||||
canonical_product_id, config_json, source, created_at, updated_at
|
||||
) VALUES
|
||||
($1, 'affiliate_dash', 10, FALSE, TRUE, $3, '{}'::jsonb, 'test', NOW(), NOW()),
|
||||
($1, 'manual', 90, TRUE, TRUE, $2, '{}'::jsonb, 'test', NOW(), NOW())
|
||||
`,
|
||||
[internalSku, manProductId, liveProductId],
|
||||
)
|
||||
|
||||
const preview = await archiveSupersededManualProducts()
|
||||
assert.equal(preview.candidateCount, 1)
|
||||
assert.ok(preview.sample.some((row) => Number(row.id) === manProductId))
|
||||
|
||||
const result = await archiveSupersededManualProducts({ execute: true })
|
||||
assert.equal(result.archivedCount, 1)
|
||||
assert.equal(result.repointedChannelCount, 1)
|
||||
|
||||
const products = await query<{ canonical_sku: string; status: string }>(
|
||||
'SELECT canonical_sku, status FROM product_catalog WHERE id = ANY($1::bigint[])',
|
||||
[[manProductId, liveProductId]],
|
||||
)
|
||||
assert.equal(products.rows.find((row) => row.canonical_sku === manSku)?.status, 'archived')
|
||||
assert.equal(products.rows.find((row) => row.canonical_sku === liveSku)?.status, 'active')
|
||||
const fallback = await query<{ canonical_product_id: number }>(
|
||||
`SELECT canonical_product_id
|
||||
FROM internal_sku_channels
|
||||
WHERE internal_sku = $1 AND channel = 'manual'`,
|
||||
[internalSku],
|
||||
)
|
||||
assert.equal(Number(fallback.rows[0]?.canonical_product_id), liveProductId)
|
||||
} finally {
|
||||
if (manProductId > 0 || liveProductId > 0) {
|
||||
await query('DELETE FROM internal_sku_channels WHERE internal_sku = $1', [internalSku])
|
||||
await query('DELETE FROM product_catalog WHERE id = ANY($1::bigint[])', [
|
||||
[manProductId, liveProductId],
|
||||
])
|
||||
}
|
||||
await closeDb()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
|
||||
|
||||
/**
|
||||
* 人工履约 SKU 配置服务。
|
||||
@@ -125,7 +126,7 @@ export async function scanManualSkus(options: ScanOptions) {
|
||||
oi.sku_name,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
|
||||
oi.manual_sku_key AS sku_key
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
|
||||
@@ -243,7 +244,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
|
||||
AND o.provider = $${params.length}
|
||||
AND oi.manual_sku_key = r.sku_key
|
||||
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = r.sku_key
|
||||
)`)
|
||||
}
|
||||
|
||||
@@ -407,7 +408,15 @@ export async function deleteManualFulfillmentConfig(skuKey: string) {
|
||||
`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1 RETURNING id`,
|
||||
[key],
|
||||
)
|
||||
await query(`DELETE FROM sku_channel_rules WHERE source = 'manual' AND match_key = $1`, [key])
|
||||
await upsertInternalSkuChannel({
|
||||
internalSku: key,
|
||||
channel: 'manual',
|
||||
priority: 90,
|
||||
isFallback: true,
|
||||
enabled: false,
|
||||
source: 'manual_config',
|
||||
configJson: { manualConfigId: null },
|
||||
})
|
||||
await syncRegistryConfigId(key, null)
|
||||
return { skuKey: key, deleted: deleted.rowCount || 0 }
|
||||
}
|
||||
@@ -435,7 +444,7 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN manual_fulfillment_configs cfg
|
||||
ON cfg.sku_key = oi.manual_sku_key
|
||||
ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
|
||||
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
|
||||
AND cfg.enabled = TRUE
|
||||
AND ($1 = '' OR cfg.sku_key = $1)
|
||||
@@ -712,34 +721,37 @@ function requiredRow<T>(row: T | undefined, message: string): T {
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工配置 → 渠道规则(内部 SKU 精确匹配)。
|
||||
*
|
||||
* 幂等:按唯一键 (provider, shop, match_key_type, match_key, channel) upsert。
|
||||
* 人工配置 → internal_sku_channels(内部 SKU 精确匹配)。
|
||||
*/
|
||||
async function syncManualChannelRule(config: {
|
||||
id: number
|
||||
sku_key: string
|
||||
canonical_product_id: number | null
|
||||
enabled: boolean
|
||||
note: string
|
||||
}): Promise<void> {
|
||||
const skuKey = String(config.sku_key || '').trim()
|
||||
if (!skuKey) return
|
||||
|
||||
await query(
|
||||
const otherChannel = await query<{ exists: boolean }>(
|
||||
`
|
||||
INSERT INTO sku_channel_rules (
|
||||
provider, shop_id, match_key_type, match_key, channel, priority,
|
||||
source, source_ref_id, enabled, note, created_at, updated_at
|
||||
)
|
||||
VALUES ('', '', 'internal_sku', $1, 'manual', 70, 'manual', $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel)
|
||||
DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
source = 'manual',
|
||||
source_ref_id = EXCLUDED.source_ref_id,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = NOW()
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM internal_sku_channels
|
||||
WHERE btrim(internal_sku) = $1 AND enabled = TRUE AND channel <> 'manual'
|
||||
) AS exists
|
||||
`,
|
||||
[skuKey, config.id, config.enabled !== false, String(config.note || '').slice(0, 200)],
|
||||
[skuKey],
|
||||
)
|
||||
const hasOtherChannel = otherChannel.rows[0]?.exists === true
|
||||
await upsertInternalSkuChannel({
|
||||
internalSku: skuKey,
|
||||
channel: 'manual',
|
||||
priority: hasOtherChannel ? 90 : 10,
|
||||
isFallback: hasOtherChannel,
|
||||
enabled: config.enabled !== false,
|
||||
canonicalProductId: config.canonical_product_id,
|
||||
source: 'manual_config',
|
||||
configJson: { manualConfigId: config.id, note: String(config.note || '').slice(0, 200) },
|
||||
replacePriority: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 2. 渠道映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧;
|
||||
* 3. 归一数量必须由回填脚本按 SKU 名称解析后写入,不预填 quantity。
|
||||
*/
|
||||
import { query } from '../../db/client.js'
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import type { AdminSession } from '../admin/admin-auth-service.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -103,12 +103,11 @@ export async function listAdminProducts(payload: JsonObject = {}) {
|
||||
`(p.canonical_sku ILIKE $${baseParams.length} OR p.canonical_name ILIKE $${baseParams.length} OR p.category ILIKE $${baseParams.length} OR root.name ILIKE $${baseParams.length})`,
|
||||
)
|
||||
}
|
||||
const status = text(payload.status)
|
||||
if (status) {
|
||||
assertOneOf(status, PRODUCT_STATUSES, '商品状态无效')
|
||||
baseParams.push(status)
|
||||
baseClauses.push(`p.status = $${baseParams.length}`)
|
||||
}
|
||||
// 商品目录默认只看当前启用商品;需要历史商品时必须显式传 status=archived。
|
||||
const status = text(payload.status) || 'active'
|
||||
assertOneOf(status, PRODUCT_STATUSES, '商品状态无效')
|
||||
baseParams.push(status)
|
||||
baseClauses.push(`p.status = $${baseParams.length}`)
|
||||
// carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。
|
||||
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
|
||||
if (source) {
|
||||
@@ -154,6 +153,18 @@ export async function listAdminProducts(payload: JsonObject = {}) {
|
||||
`
|
||||
SELECT
|
||||
p.*,
|
||||
tpl_rule.pricing_mode AS template_pricing_mode,
|
||||
tpl_rule.enabled AS template_enabled,
|
||||
tpl_rule.sharing_enabled AS template_sharing_enabled,
|
||||
(
|
||||
SELECT c.channel
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled = TRUE
|
||||
AND c.is_fallback = FALSE
|
||||
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
|
||||
ORDER BY c.priority, c.channel
|
||||
LIMIT 1
|
||||
) AS active_primary_channel,
|
||||
CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id,
|
||||
CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name,
|
||||
root.id AS root_category_id,
|
||||
@@ -192,20 +203,6 @@ export async function listAdminProducts(payload: JsonObject = {}) {
|
||||
AND wpr.rule_key = substring(p.canonical_sku from 5)
|
||||
ORDER BY v.effective_from DESC, v.id DESC
|
||||
LIMIT 1
|
||||
),
|
||||
-- 还没有成本版本的接单模板商品(例如刚建、还没跑「同步模板发单金额」),
|
||||
-- 直接展示模板同步过来的发单金额,避免显示「未配置」让改价入口看起来不可用。
|
||||
(
|
||||
SELECT CASE
|
||||
WHEN wpr2.unit_price_fen > 0 AND wpr2.reward_amount = 0
|
||||
THEN wpr2.unit_price_fen
|
||||
ELSE wpr2.reward_amount
|
||||
END
|
||||
FROM work_product_rules wpr2
|
||||
WHERE p.canonical_sku LIKE 'tpl:%'
|
||||
AND wpr2.rule_key = substring(p.canonical_sku from 5)
|
||||
AND (wpr2.reward_amount > 0 OR wpr2.unit_price_fen > 0)
|
||||
LIMIT 1
|
||||
)
|
||||
) AS active_cost_amount_fen,
|
||||
COALESCE(
|
||||
@@ -232,6 +229,9 @@ export async function listAdminProducts(payload: JsonObject = {}) {
|
||||
${affiliateColumnsSql}
|
||||
FROM product_catalog p
|
||||
${PRODUCT_CATEGORY_JOINS}
|
||||
LEFT JOIN work_product_rules tpl_rule
|
||||
ON p.canonical_sku LIKE 'tpl:%'
|
||||
AND tpl_rule.rule_key = substring(p.canonical_sku from 5)
|
||||
${affiliateTaskJoinSql}
|
||||
${where}
|
||||
ORDER BY
|
||||
@@ -438,6 +438,162 @@ export async function deleteAdminProduct(productId: number | string) {
|
||||
return { deleted: true, product: mapProduct(current) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量归档旧人工归一商品。
|
||||
*
|
||||
* 只处理 attributes_json.source=manual_normalization、当前为 active,
|
||||
* 且所有关联订单项都已 archived 的商品。只改 status,不删除任何数据。
|
||||
*/
|
||||
export async function archiveHistoricalManualProducts(payload: JsonObject = {}) {
|
||||
const execute = payload.execute === true || payload.execute === 'true'
|
||||
const candidates = await query<{ id: number; canonical_sku: string; canonical_name: string }>(
|
||||
`
|
||||
SELECT p.id, p.canonical_sku, p.canonical_name
|
||||
FROM product_catalog p
|
||||
WHERE p.status = 'active'
|
||||
AND p.canonical_sku LIKE 'man:%'
|
||||
AND p.attributes_json->>'source' = 'manual_normalization'
|
||||
AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.canonical_product_id = p.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.canonical_product_id = p.id
|
||||
AND oi.fulfillment_status <> 'archived'
|
||||
)
|
||||
ORDER BY p.id
|
||||
`,
|
||||
)
|
||||
const ids = candidates.rows.map((row) => Number(row.id))
|
||||
if (!execute || ids.length === 0) {
|
||||
return {
|
||||
dryRun: !execute,
|
||||
candidateCount: ids.length,
|
||||
archivedCount: 0,
|
||||
sample: candidates.rows.slice(0, 20),
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await query(
|
||||
`UPDATE product_catalog
|
||||
SET status = 'archived', updated_at = NOW()
|
||||
WHERE id = ANY($1::bigint[]) AND status = 'active'`,
|
||||
[ids],
|
||||
)
|
||||
return {
|
||||
dryRun: false,
|
||||
candidateCount: ids.length,
|
||||
archivedCount: updated.rowCount || 0,
|
||||
sample: candidates.rows.slice(0, 20),
|
||||
}
|
||||
}
|
||||
|
||||
type SupersededManualProductRow = {
|
||||
id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
primary_channel: string
|
||||
target_product_id: number
|
||||
target_product_sku: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档已被其他渠道主规则接管的旧人工归一商品。
|
||||
*
|
||||
* `man:` 只表示旧人工来源,不代表当前主渠道。同名内部 SKU 已有自动发货、
|
||||
* 接单平台或卡券主渠道后,旧 man: 商品不再参与新路由,只需从当前目录归档。
|
||||
* 历史订单、成本版本和成本快照全部保留;仍指向旧商品的手动兜底改指当前主商品。
|
||||
*/
|
||||
export async function archiveSupersededManualProducts(payload: JsonObject = {}) {
|
||||
const execute = payload.execute === true || payload.execute === 'true'
|
||||
return withTransaction(async (client) => {
|
||||
const candidates = await client.query<SupersededManualProductRow>(
|
||||
`
|
||||
SELECT
|
||||
p.id,
|
||||
p.canonical_sku,
|
||||
p.canonical_name,
|
||||
live.channel AS primary_channel,
|
||||
target.id AS target_product_id,
|
||||
target.canonical_sku AS target_product_sku
|
||||
FROM product_catalog p
|
||||
JOIN LATERAL (
|
||||
SELECT c.channel, c.canonical_product_id
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled = TRUE
|
||||
AND c.is_fallback = FALSE
|
||||
AND c.channel <> 'manual'
|
||||
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
|
||||
ORDER BY c.priority, c.channel
|
||||
LIMIT 1
|
||||
) live ON TRUE
|
||||
JOIN product_catalog target
|
||||
ON target.id = live.canonical_product_id
|
||||
AND target.status = 'active'
|
||||
WHERE p.status = 'active'
|
||||
AND p.canonical_sku LIKE 'man:%'
|
||||
AND p.attributes_json->>'source' = 'manual_normalization'
|
||||
ORDER BY p.id
|
||||
FOR UPDATE OF p
|
||||
`,
|
||||
)
|
||||
const rows = candidates.rows.map((row) => ({
|
||||
...row,
|
||||
id: Number(row.id),
|
||||
target_product_id: Number(row.target_product_id),
|
||||
}))
|
||||
if (!execute || rows.length === 0) {
|
||||
return {
|
||||
dryRun: !execute,
|
||||
candidateCount: rows.length,
|
||||
archivedCount: 0,
|
||||
repointedChannelCount: 0,
|
||||
sample: rows.slice(0, 20),
|
||||
}
|
||||
}
|
||||
|
||||
const repointed = await client.query(
|
||||
`
|
||||
UPDATE internal_sku_channels c
|
||||
SET canonical_product_id = input.target_product_id,
|
||||
updated_at = NOW()
|
||||
FROM unnest($1::text[], $2::bigint[], $3::bigint[])
|
||||
AS input(internal_sku, source_product_id, target_product_id)
|
||||
WHERE c.internal_sku = input.internal_sku
|
||||
AND c.channel = 'manual'
|
||||
AND c.canonical_product_id = input.source_product_id
|
||||
`,
|
||||
[
|
||||
rows.map((row) => row.canonical_name),
|
||||
rows.map((row) => row.id),
|
||||
rows.map((row) => row.target_product_id),
|
||||
],
|
||||
)
|
||||
const archived = await client.query(
|
||||
`
|
||||
UPDATE product_catalog
|
||||
SET status = 'archived',
|
||||
attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = ANY($2::bigint[]) AND status = 'active'
|
||||
`,
|
||||
[
|
||||
JSON.stringify({
|
||||
legacyManualArchived: true,
|
||||
legacyManualArchiveReason: 'superseded_by_active_primary_channel',
|
||||
legacyManualArchivedAt: nowIso(),
|
||||
}),
|
||||
rows.map((row) => row.id),
|
||||
],
|
||||
)
|
||||
return {
|
||||
dryRun: false,
|
||||
candidateCount: rows.length,
|
||||
archivedCount: archived.rowCount || 0,
|
||||
repointedChannelCount: repointed.rowCount || 0,
|
||||
sample: rows.slice(0, 20),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type MappingProductGroupRow = {
|
||||
canonical_product_id: number
|
||||
canonical_sku: string
|
||||
@@ -1388,13 +1544,51 @@ export async function setAdminProductPrice(
|
||||
id: number
|
||||
rule_key: string
|
||||
product_name: string
|
||||
enabled: boolean
|
||||
reward_amount: number
|
||||
unit_price_fen: number
|
||||
pricing_mode: 'fixed' | 'unit'
|
||||
sharing_enabled: boolean
|
||||
sharing_auto_from_order: boolean
|
||||
}>(
|
||||
'SELECT id, rule_key, product_name, reward_amount, unit_price_fen FROM work_product_rules WHERE rule_key = $1 LIMIT 1',
|
||||
`SELECT id, rule_key, product_name, enabled, reward_amount, unit_price_fen, pricing_mode,
|
||||
sharing_enabled, sharing_auto_from_order
|
||||
FROM work_product_rules
|
||||
WHERE rule_key = $1
|
||||
LIMIT 1`,
|
||||
[ruleKey],
|
||||
)
|
||||
const rule = ruleResult.rows[0]
|
||||
if (!rule) {
|
||||
throw createHttpError('接单模板商品没有关联模板,请先修复商品目录', {
|
||||
statusCode: 409,
|
||||
errorCode: 'cost_work_order_template_missing',
|
||||
})
|
||||
}
|
||||
const rawPricingMode = text(payload.pricingMode ?? payload.pricing_mode)
|
||||
if (rawPricingMode) {
|
||||
throw createHttpError('接单模板计价方式只能在【接单模板】中设置', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_work_order_pricing_mode_managed_by_template',
|
||||
})
|
||||
}
|
||||
const pricingMode: 'fixed' | 'unit' = rule.pricing_mode === 'unit' ? 'unit' : 'fixed'
|
||||
if (pricingMode === 'fixed' && rule.sharing_enabled && rule.sharing_auto_from_order) {
|
||||
throw createHttpError(
|
||||
'拼单自动按件计价与固定金额冲突,请先在接单模板关闭拼单或改为手工拼单价',
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_work_order_sharing_pricing_conflict',
|
||||
},
|
||||
)
|
||||
}
|
||||
// 模板运行价格只有一个当前值;未来生效无法与成本版本同时切换,因此暂只允许立即生效。
|
||||
if (Date.parse(effectiveFrom) > Date.now() + 5_000) {
|
||||
throw createHttpError('接单模板成本暂不支持未来生效,请选择立即生效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_work_order_future_price_not_supported',
|
||||
})
|
||||
}
|
||||
|
||||
const costItemResult = await query<{ id: number }>(
|
||||
"SELECT id FROM cost_items WHERE code = 'sys_work_order_reward' OR cost_model = 'work_order_reward' LIMIT 1",
|
||||
@@ -1403,123 +1597,138 @@ export async function setAdminProductPrice(
|
||||
if (!costItemId) {
|
||||
throw createHttpError('未找到工单结算成本项(sys_work_order_reward)', { statusCode: 500 })
|
||||
}
|
||||
|
||||
// 查找已有的 active 版本 (按 rule_id 或 canonical_product_id)
|
||||
const existingResult = await query<{ id: number; amount_fen: number; effective_from: string }>(
|
||||
`SELECT id, amount_fen, effective_from FROM cost_versions
|
||||
WHERE cost_item_id = $1 AND (work_product_rule_id = $2 OR canonical_product_id = $3) AND status = 'active'
|
||||
ORDER BY effective_from DESC, id DESC LIMIT 1`,
|
||||
[costItemId, rule?.id ?? null, product.id],
|
||||
)
|
||||
const existing = existingResult.rows[0]
|
||||
const existingTime = existing ? Date.parse(existing.effective_from) : null
|
||||
const targetTime = Date.parse(effectiveFrom)
|
||||
|
||||
let costVersionId = existing?.id || 0
|
||||
|
||||
if (existing && existingTime !== null && targetTime < existingTime) {
|
||||
// 追溯改价:退休旧版本并新增一个从 targetTime 起生效的版本,旧行保留可追溯。
|
||||
// 不能就地改金额,否则 cost_snapshots.cost_version_id 指向的版本内容会被篡改。
|
||||
await query("UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", [
|
||||
now,
|
||||
existing.id,
|
||||
])
|
||||
const inserted = await query<{ id: number }>(
|
||||
`INSERT INTO cost_versions (
|
||||
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
|
||||
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
|
||||
source, status, change_reason, created_by_admin_user_id,
|
||||
approved_by_admin_user_id, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, 'rule', '', '', $2,
|
||||
$3, $4, 0, $5, NULL,
|
||||
'manual', 'active', $6, $7,
|
||||
$7, $8, $8
|
||||
) RETURNING id`,
|
||||
[
|
||||
costItemId,
|
||||
product.id,
|
||||
rule?.id ?? null,
|
||||
amountFen,
|
||||
effectiveFrom,
|
||||
changeReason,
|
||||
session.userId,
|
||||
now,
|
||||
],
|
||||
const projectedRewardAmount = pricingMode === 'unit' ? 0 : amountFen
|
||||
const projectedUnitPrice = pricingMode === 'unit' ? amountFen : 0
|
||||
const costVersionId = await withTransaction(async (client) => {
|
||||
const existingResult = await client.query<{
|
||||
id: number
|
||||
amount_fen: number
|
||||
effective_from: string
|
||||
}>(
|
||||
`SELECT id, amount_fen, effective_from
|
||||
FROM cost_versions
|
||||
WHERE cost_item_id = $1
|
||||
AND work_product_rule_id = $2
|
||||
AND status = 'active'
|
||||
ORDER BY effective_from DESC, id DESC
|
||||
LIMIT 1`,
|
||||
[costItemId, rule.id],
|
||||
)
|
||||
costVersionId = inserted.rows[0]?.id || 0
|
||||
} else if (existing && existingTime !== null && targetTime === existingTime) {
|
||||
// 相同生效时间的更正:没有可插入的时间区间,只能就地更正并保留变更原因。
|
||||
await query(
|
||||
`UPDATE cost_versions
|
||||
SET amount_fen = $1,
|
||||
change_reason = $2,
|
||||
created_by_admin_user_id = $3,
|
||||
approved_by_admin_user_id = $3,
|
||||
updated_at = $4
|
||||
WHERE id = $5`,
|
||||
[amountFen, changeReason, session.userId, now, existing.id],
|
||||
)
|
||||
} else {
|
||||
if (existing) {
|
||||
await query(
|
||||
"UPDATE cost_versions SET status = 'retired', effective_to = $1, updated_at = $2 WHERE id = $3",
|
||||
[effectiveFrom, now, existing.id],
|
||||
const existing = existingResult.rows[0]
|
||||
const existingTime = existing ? Date.parse(existing.effective_from) : null
|
||||
const targetTime = Date.parse(effectiveFrom)
|
||||
let nextCostVersionId = existing?.id || 0
|
||||
|
||||
if (existing && existingTime !== null && targetTime < existingTime) {
|
||||
await client.query(
|
||||
"UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2",
|
||||
[now, existing.id],
|
||||
)
|
||||
const inserted = await client.query<{ id: number }>(
|
||||
`INSERT INTO cost_versions (
|
||||
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
|
||||
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
|
||||
source, status, change_reason, created_by_admin_user_id,
|
||||
approved_by_admin_user_id, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, 'rule', '', '', $2,
|
||||
$3, $4, 0, $5, NULL,
|
||||
'manual', 'active', $6, $7,
|
||||
$7, $8, $8
|
||||
) RETURNING id`,
|
||||
[
|
||||
costItemId,
|
||||
product.id,
|
||||
rule.id,
|
||||
amountFen,
|
||||
effectiveFrom,
|
||||
changeReason,
|
||||
session.userId,
|
||||
now,
|
||||
],
|
||||
)
|
||||
nextCostVersionId = inserted.rows[0]?.id || 0
|
||||
} else if (existing && existingTime !== null && targetTime === existingTime) {
|
||||
await client.query(
|
||||
`UPDATE cost_versions
|
||||
SET amount_fen = $1,
|
||||
change_reason = $2,
|
||||
created_by_admin_user_id = $3,
|
||||
approved_by_admin_user_id = $3,
|
||||
updated_at = $4
|
||||
WHERE id = $5`,
|
||||
[amountFen, changeReason, session.userId, now, existing.id],
|
||||
)
|
||||
} else {
|
||||
if (existing) {
|
||||
await client.query(
|
||||
"UPDATE cost_versions SET status = 'retired', effective_to = $1, updated_at = $2 WHERE id = $3",
|
||||
[effectiveFrom, now, existing.id],
|
||||
)
|
||||
}
|
||||
const inserted = await client.query<{ id: number }>(
|
||||
`INSERT INTO cost_versions (
|
||||
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
|
||||
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
|
||||
source, status, change_reason, created_by_admin_user_id,
|
||||
approved_by_admin_user_id, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, 'rule', '', '', $2,
|
||||
$3, $4, 0, $5, NULL,
|
||||
'manual', 'active', $6, $7,
|
||||
$7, $8, $8
|
||||
) RETURNING id`,
|
||||
[
|
||||
costItemId,
|
||||
product.id,
|
||||
rule.id,
|
||||
amountFen,
|
||||
effectiveFrom,
|
||||
changeReason,
|
||||
session.userId,
|
||||
now,
|
||||
],
|
||||
)
|
||||
nextCostVersionId = inserted.rows[0]?.id || 0
|
||||
}
|
||||
const inserted = await query<{ id: number }>(
|
||||
`INSERT INTO cost_versions (
|
||||
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
|
||||
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
|
||||
source, status, change_reason, created_by_admin_user_id,
|
||||
approved_by_admin_user_id, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, 'rule', '', '', $2,
|
||||
$3, $4, 0, $5, NULL,
|
||||
'manual', 'active', $6, $7,
|
||||
$7, $8, $8
|
||||
) RETURNING id`,
|
||||
[
|
||||
costItemId,
|
||||
product.id,
|
||||
rule?.id ?? null,
|
||||
amountFen,
|
||||
effectiveFrom,
|
||||
changeReason,
|
||||
session.userId,
|
||||
now,
|
||||
],
|
||||
)
|
||||
costVersionId = inserted.rows[0]?.id || 0
|
||||
}
|
||||
|
||||
// 同步更新 work_product_rules 保持数据一致
|
||||
if (rule) {
|
||||
await query(
|
||||
const updatedRule = await client.query(
|
||||
`UPDATE work_product_rules
|
||||
SET reward_amount = $1::bigint,
|
||||
unit_price_fen = CASE WHEN unit_price_fen > 0 THEN $1::bigint ELSE 0::bigint END,
|
||||
cost_item_id = $2,
|
||||
updated_at = $3
|
||||
WHERE id = $4`,
|
||||
[amountFen, costItemId, now, rule.id],
|
||||
unit_price_fen = $2::bigint,
|
||||
cost_item_id = $3,
|
||||
updated_at = $4
|
||||
WHERE id = $5
|
||||
RETURNING id`,
|
||||
[projectedRewardAmount, projectedUnitPrice, costItemId, now, rule.id],
|
||||
)
|
||||
}
|
||||
|
||||
// 更新 product_catalog.attributes_json
|
||||
const attr =
|
||||
typeof product.attributes_json === 'object' && product.attributes_json
|
||||
? { ...(product.attributes_json as Record<string, unknown>) }
|
||||
: {}
|
||||
attr.rewardAmountFen = amountFen
|
||||
await query(
|
||||
'UPDATE product_catalog SET attributes_json = $1::jsonb, updated_at = $2 WHERE id = $3',
|
||||
[JSON.stringify(attr), now, product.id],
|
||||
)
|
||||
if (updatedRule.rowCount !== 1) {
|
||||
throw createHttpError('接单模板价格投影失败', { statusCode: 500 })
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE product_catalog
|
||||
SET attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb,
|
||||
updated_at = $2
|
||||
WHERE id = $3`,
|
||||
[
|
||||
JSON.stringify({
|
||||
pricingMode,
|
||||
rewardAmountFen: projectedRewardAmount,
|
||||
unitPriceFen: projectedUnitPrice,
|
||||
}),
|
||||
now,
|
||||
product.id,
|
||||
],
|
||||
)
|
||||
if (!nextCostVersionId) {
|
||||
throw createHttpError('接单模板成本版本保存失败', { statusCode: 500 })
|
||||
}
|
||||
return nextCostVersionId
|
||||
})
|
||||
|
||||
// 如果指定了同步工单价格(默认 true)
|
||||
let syncedOrders: unknown = null
|
||||
if (payload.syncOpenOrders !== false && rule) {
|
||||
if (payload.syncOpenOrders !== false) {
|
||||
syncedOrders = await syncAdminWorkProductRulePrice(rule.id, session.username)
|
||||
}
|
||||
|
||||
@@ -1529,6 +1738,7 @@ export async function setAdminProductPrice(
|
||||
costVersionId,
|
||||
amountFen,
|
||||
effectiveFrom,
|
||||
pricingMode,
|
||||
syncedOrders,
|
||||
message: '接单模板结算价格已更新',
|
||||
}
|
||||
|
||||
@@ -198,38 +198,10 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
|
||||
priority: 10,
|
||||
isFallback: false,
|
||||
canonicalProductId: productId,
|
||||
configJson: { voucherSku: internalSku },
|
||||
source: 'auto',
|
||||
}).catch(() => undefined)
|
||||
|
||||
// 同一内部中文 SKU 跨平台码统一分流到卡券渠道。
|
||||
await query(
|
||||
`
|
||||
INSERT INTO sku_channel_rules (
|
||||
provider, shop_id, match_key_type, match_key, channel, priority,
|
||||
source, enabled, note, created_at, updated_at
|
||||
)
|
||||
VALUES ('', '', 'internal_sku', $1, 'third_party_voucher', 10, 'voucher', TRUE,
|
||||
'卡券商品自动建商品后生成', NOW(), NOW())
|
||||
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING
|
||||
`,
|
||||
[internalSku],
|
||||
)
|
||||
|
||||
const skuCode = String(item.sku_code || '').trim()
|
||||
if (skuCode) {
|
||||
await query(
|
||||
`
|
||||
INSERT INTO sku_channel_rules (
|
||||
provider, shop_id, match_key_type, match_key, channel, priority,
|
||||
source, enabled, note, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, 'sku_code', $3, 'third_party_voucher', 10, 'voucher', TRUE,
|
||||
'卡券订单自动建商品后生成', NOW(), NOW())
|
||||
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING
|
||||
`,
|
||||
[item.provider, item.shop_id, skuCode],
|
||||
)
|
||||
}
|
||||
return productId
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
backfillAdminNormalizedQuantities,
|
||||
backfillAdminProductMappings,
|
||||
} from './product-catalog-service.js'
|
||||
import { syncAdminWorkOrderTemplateCostVersions } from './work-order-product-service.js'
|
||||
import {
|
||||
buildTemplateProductSku,
|
||||
inferTemplateUnitName,
|
||||
} from './work-order-template-product-service.js'
|
||||
|
||||
export { buildTemplateProductSku, inferTemplateUnitName }
|
||||
|
||||
export async function previewAdminTemplateProductBootstrap(payload: JsonObject = {}) {
|
||||
const range = resolveBootstrapDateRange(payload)
|
||||
@@ -123,6 +128,7 @@ export async function applyAdminTemplateProductBootstrap(
|
||||
skuNick: template.sku_nick,
|
||||
relSkuId: template.rel_sku_id,
|
||||
sellerIds: template.seller_ids_json,
|
||||
pricingMode: template.pricing_mode === 'unit' ? 'unit' : 'fixed',
|
||||
unitPriceFen: Number(template.unit_price_fen || 0),
|
||||
rewardAmountFen: Number(template.reward_amount || 0),
|
||||
templateEnabled: template.enabled,
|
||||
@@ -241,8 +247,6 @@ export async function applyAdminTemplateProductBootstrap(
|
||||
dateTo: range.toDate,
|
||||
})
|
||||
mappedOrderItemCount = mappingBackfill.updatedCount
|
||||
const templateCosts = await syncAdminWorkOrderTemplateCostVersions()
|
||||
|
||||
return {
|
||||
dateFrom: range.fromDate,
|
||||
dateTo: range.toDate,
|
||||
@@ -260,9 +264,6 @@ export async function applyAdminTemplateProductBootstrap(
|
||||
resetOrderItemCount: resetResult.resetOrderItemCount,
|
||||
mappingConflictCount: plan.conflicts.length,
|
||||
mappingConflictItemCount: plan.unresolvedItemCount,
|
||||
templateCostVersionCount: templateCosts.activeVersionCount,
|
||||
templateCostCreatedCount: templateCosts.createdCount,
|
||||
templateCostRetiredCount: templateCosts.retiredCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +283,7 @@ type TemplateBootstrapTemplateRow = {
|
||||
category: string
|
||||
unit_price_fen: number
|
||||
reward_amount: number
|
||||
pricing_mode: 'fixed' | 'unit'
|
||||
mapping_id: number | null
|
||||
mapping_type: string
|
||||
sku_nick: string
|
||||
@@ -696,6 +698,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
|
||||
COALESCE(c.name, '') AS category,
|
||||
r.unit_price_fen,
|
||||
r.reward_amount,
|
||||
r.pricing_mode,
|
||||
m.id AS mapping_id,
|
||||
COALESCE(m.mapping_type, '') AS mapping_type,
|
||||
COALESCE(m.sku_nick, '') AS sku_nick,
|
||||
@@ -806,19 +809,6 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTemplateProductSku(ruleKey: string) {
|
||||
return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64)
|
||||
}
|
||||
|
||||
export function inferTemplateUnitName(productName: string) {
|
||||
const name = String(productName || '').trim()
|
||||
if (/钻石|颗/.test(name)) return '颗'
|
||||
if (/枪|把/.test(name)) return '把'
|
||||
if (/套/.test(name)) return '套'
|
||||
if (/密钥|秘钥/.test(name)) return '个'
|
||||
return '件'
|
||||
}
|
||||
|
||||
function resolveBootstrapDateRange(payload: JsonObject) {
|
||||
const range = resolveFinanceDateRange(
|
||||
{ dateFrom: payload.dateFrom, dateTo: payload.dateTo },
|
||||
|
||||
@@ -311,7 +311,9 @@ type WorkOrderTemplateCostSyncRow = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把接单模板的发单金额同步成规则级成本版本。
|
||||
* 历史迁移/灾后修复:把既有接单模板发单金额补齐为规则级成本版本。
|
||||
*
|
||||
* 日常成本真源是商品与成本中心;模板保存不再调用本函数,前端也不再提供按钮。
|
||||
*
|
||||
* 固定金额模板取 `reward_amount`;按件模板取 `unit_price_fen`,数量由订单 SKU
|
||||
* 决定。全局 0 元版本只作为“按工单实际结算”的占位,不再代表模板价格。
|
||||
@@ -325,7 +327,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) {
|
||||
SELECT
|
||||
wpr.id AS rule_id,
|
||||
CASE
|
||||
WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 THEN wpr.unit_price_fen
|
||||
WHEN wpr.pricing_mode = 'unit' THEN wpr.unit_price_fen
|
||||
ELSE wpr.reward_amount
|
||||
END AS amount_fen
|
||||
FROM work_product_rules wpr
|
||||
@@ -362,7 +364,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) {
|
||||
SELECT
|
||||
wpr.id AS rule_id,
|
||||
CASE
|
||||
WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 THEN wpr.unit_price_fen
|
||||
WHEN wpr.pricing_mode = 'unit' THEN wpr.unit_price_fen
|
||||
ELSE wpr.reward_amount
|
||||
END AS amount_fen
|
||||
FROM work_product_rules wpr
|
||||
@@ -414,7 +416,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) {
|
||||
'import',
|
||||
'active',
|
||||
CASE
|
||||
WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0
|
||||
WHEN wpr.pricing_mode = 'unit'
|
||||
THEN '模板按件发单单价(数量取订单 SKU)'
|
||||
ELSE '模板固定发单金额'
|
||||
END,
|
||||
@@ -467,7 +469,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) {
|
||||
SELECT
|
||||
COUNT(*)::int AS rule_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wpr.unit_price_fen > 0 AND wpr.reward_amount = 0
|
||||
WHERE wpr.pricing_mode = 'unit'
|
||||
)::int AS unit_price_rule_count,
|
||||
COUNT(v.id) FILTER (
|
||||
WHERE v.scope_type = 'rule' AND v.status = 'active'
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 接单模板成本单一写入口集成测试(需要真实 PostgreSQL)。
|
||||
*
|
||||
* WORK_ORDER_TEMPLATE_COST_TEST_DATABASE_URL=postgres://... \
|
||||
* npx tsx --test src/services/cost-center/work-order-template-cost.integration.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const TEST_DATABASE_URL = String(
|
||||
process.env.WORK_ORDER_TEMPLATE_COST_TEST_DATABASE_URL || '',
|
||||
).trim()
|
||||
|
||||
test(
|
||||
'模板自动生成商品,成本中心保存价格后才允许启用',
|
||||
{ skip: !TEST_DATABASE_URL ? '未设置 WORK_ORDER_TEMPLATE_COST_TEST_DATABASE_URL' : false },
|
||||
async () => {
|
||||
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||
const { closeDb, query } = await import('../../db/client.js')
|
||||
const { saveAdminWorkProductRule } =
|
||||
await import('../worker-platform/admin-worker-catalog-service.js')
|
||||
const { setAdminProductPrice } = await import('./product-catalog-service.js')
|
||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||
const productName = `集成测试接单模板-${suffix}`
|
||||
let ruleId = 0
|
||||
let productId = 0
|
||||
|
||||
try {
|
||||
const admin = await query<{ id: number; username: string; display_name: string }>(
|
||||
'SELECT id, username, display_name FROM admin_users ORDER BY id LIMIT 1',
|
||||
)
|
||||
const adminUser = admin.rows[0]
|
||||
assert.ok(adminUser, '集成测试库至少需要一个后台账号')
|
||||
|
||||
const created = await saveAdminWorkProductRule({
|
||||
productName,
|
||||
itemTitle: productName,
|
||||
itemTitleMatchType: 'exact',
|
||||
matchType: 'exact',
|
||||
enabled: true,
|
||||
autoCreate: true,
|
||||
})
|
||||
ruleId = Number(created.rule.ruleId)
|
||||
productId = Number(created.productId)
|
||||
assert.ok(ruleId > 0)
|
||||
assert.ok(productId > 0)
|
||||
assert.equal(created.rule.enabled, false)
|
||||
assert.equal(created.rule.pricingMode, 'fixed')
|
||||
assert.equal(created.costConfigurationRequired, true)
|
||||
|
||||
const product = await query<{ canonical_sku: string; status: string }>(
|
||||
'SELECT canonical_sku, status FROM product_catalog WHERE id = $1',
|
||||
[productId],
|
||||
)
|
||||
assert.equal(product.rows[0]?.canonical_sku, `tpl:${created.rule.ruleKey}`)
|
||||
assert.equal(product.rows[0]?.status, 'active')
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
saveAdminWorkProductRule({
|
||||
ruleId,
|
||||
productName,
|
||||
enabled: true,
|
||||
}),
|
||||
/先在商品与成本中心配置接单模板成本/,
|
||||
)
|
||||
await assert.rejects(
|
||||
() =>
|
||||
saveAdminWorkProductRule({
|
||||
ruleId,
|
||||
productName,
|
||||
rewardAmount: 999,
|
||||
}),
|
||||
/价格只能由【商品与成本中心】维护/,
|
||||
)
|
||||
|
||||
const price = await setAdminProductPrice(
|
||||
productId,
|
||||
{
|
||||
amountFen: 1234,
|
||||
changeReason: '接单模板成本集成测试',
|
||||
syncOpenOrders: false,
|
||||
},
|
||||
{
|
||||
sessionId: `it-${suffix}`,
|
||||
userId: Number(adminUser.id),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.display_name || adminUser.username,
|
||||
role: 'admin',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
absoluteExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
sessionVersion: 1,
|
||||
deviceId: 'integration-test',
|
||||
permissions: [],
|
||||
},
|
||||
)
|
||||
assert.ok(price.costVersionId > 0)
|
||||
|
||||
const projection = await query<{
|
||||
enabled: boolean
|
||||
reward_amount: number
|
||||
unit_price_fen: number
|
||||
pricing_mode: string
|
||||
amount_fen: number
|
||||
}>(
|
||||
`
|
||||
SELECT wpr.enabled, wpr.reward_amount, wpr.unit_price_fen, wpr.pricing_mode, v.amount_fen
|
||||
FROM work_product_rules wpr
|
||||
JOIN cost_versions v ON v.work_product_rule_id = wpr.id AND v.status = 'active'
|
||||
WHERE wpr.id = $1
|
||||
ORDER BY v.effective_from DESC, v.id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[ruleId],
|
||||
)
|
||||
assert.equal(projection.rows[0]?.enabled, false)
|
||||
assert.equal(Number(projection.rows[0]?.reward_amount), 1234)
|
||||
assert.equal(Number(projection.rows[0]?.unit_price_fen), 0)
|
||||
assert.equal(projection.rows[0]?.pricing_mode, 'fixed')
|
||||
assert.equal(Number(projection.rows[0]?.amount_fen), 1234)
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
setAdminProductPrice(
|
||||
productId,
|
||||
{
|
||||
amountFen: 2000,
|
||||
pricingMode: 'fixed',
|
||||
syncOpenOrders: false,
|
||||
},
|
||||
{
|
||||
sessionId: `it-${suffix}`,
|
||||
userId: Number(adminUser.id),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.display_name || adminUser.username,
|
||||
role: 'admin',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
absoluteExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
sessionVersion: 1,
|
||||
deviceId: 'integration-test',
|
||||
permissions: [],
|
||||
},
|
||||
),
|
||||
/计价方式只能在【接单模板】中设置/,
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
setAdminProductPrice(
|
||||
productId,
|
||||
{
|
||||
amountFen: 2000,
|
||||
effectiveFrom: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
syncOpenOrders: false,
|
||||
},
|
||||
{
|
||||
sessionId: `it-${suffix}`,
|
||||
userId: Number(adminUser.id),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.display_name || adminUser.username,
|
||||
role: 'admin',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
absoluteExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
sessionVersion: 1,
|
||||
deviceId: 'integration-test',
|
||||
permissions: [],
|
||||
},
|
||||
),
|
||||
/暂不支持未来生效/,
|
||||
)
|
||||
|
||||
const unitModeRule = await saveAdminWorkProductRule({
|
||||
ruleId,
|
||||
productName,
|
||||
pricingMode: 'unit',
|
||||
})
|
||||
assert.equal(unitModeRule.rule.pricingMode, 'unit')
|
||||
assert.equal(unitModeRule.rule.unitPriceFen, 1234)
|
||||
assert.equal(unitModeRule.rule.rewardAmount, 0)
|
||||
|
||||
await setAdminProductPrice(
|
||||
productId,
|
||||
{
|
||||
amountFen: 1300,
|
||||
changeReason: '接单模板按件成本集成测试',
|
||||
syncOpenOrders: false,
|
||||
},
|
||||
{
|
||||
sessionId: `it-${suffix}`,
|
||||
userId: Number(adminUser.id),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.display_name || adminUser.username,
|
||||
role: 'admin',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
absoluteExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
sessionVersion: 1,
|
||||
deviceId: 'integration-test',
|
||||
permissions: [],
|
||||
},
|
||||
)
|
||||
const unitProjection = await query<{
|
||||
reward_amount: number
|
||||
unit_price_fen: number
|
||||
pricing_mode: string
|
||||
amount_fen: number
|
||||
}>(
|
||||
`
|
||||
SELECT wpr.reward_amount, wpr.unit_price_fen, wpr.pricing_mode, v.amount_fen
|
||||
FROM work_product_rules wpr
|
||||
JOIN cost_versions v ON v.work_product_rule_id = wpr.id AND v.status = 'active'
|
||||
WHERE wpr.id = $1
|
||||
ORDER BY v.effective_from DESC, v.id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[ruleId],
|
||||
)
|
||||
assert.equal(Number(unitProjection.rows[0]?.reward_amount), 0)
|
||||
assert.equal(Number(unitProjection.rows[0]?.unit_price_fen), 1300)
|
||||
assert.equal(unitProjection.rows[0]?.pricing_mode, 'unit')
|
||||
assert.equal(Number(unitProjection.rows[0]?.amount_fen), 1300)
|
||||
|
||||
const fixedModeRule = await saveAdminWorkProductRule({
|
||||
ruleId,
|
||||
productName,
|
||||
pricingMode: 'fixed',
|
||||
})
|
||||
assert.equal(fixedModeRule.rule.pricingMode, 'fixed')
|
||||
assert.equal(fixedModeRule.rule.rewardAmount, 1300)
|
||||
assert.equal(fixedModeRule.rule.unitPriceFen, 0)
|
||||
|
||||
const enabled = await saveAdminWorkProductRule({
|
||||
ruleId,
|
||||
productName,
|
||||
enabled: true,
|
||||
})
|
||||
assert.equal(enabled.rule.enabled, true)
|
||||
assert.equal(enabled.costConfigurationRequired, false)
|
||||
} finally {
|
||||
if (ruleId > 0 || productId > 0) {
|
||||
await query('DELETE FROM cost_snapshots WHERE canonical_product_id = $1', [productId])
|
||||
await query(
|
||||
`DELETE FROM cost_versions
|
||||
WHERE work_product_rule_id = $1 OR canonical_product_id = $2`,
|
||||
[ruleId, productId],
|
||||
)
|
||||
await query('DELETE FROM channel_product_mappings WHERE canonical_product_id = $1', [
|
||||
productId,
|
||||
])
|
||||
await query('DELETE FROM product_channel_capabilities WHERE product_id = $1', [productId])
|
||||
await query(
|
||||
'UPDATE internal_sku_channels SET canonical_product_id = NULL WHERE canonical_product_id = $1',
|
||||
[productId],
|
||||
)
|
||||
await query('DELETE FROM product_catalog WHERE id = $1', [productId])
|
||||
await query('DELETE FROM work_product_rules WHERE id = $1', [ruleId])
|
||||
}
|
||||
await closeDb()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { PoolClient, QueryResult, QueryResultRow } from 'pg'
|
||||
|
||||
import { query } from '../../db/client.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js'
|
||||
|
||||
type DbClient = Pick<PoolClient, 'query'>
|
||||
|
||||
type WorkOrderTemplateProjectionRow = WorkProductRuleRow & {
|
||||
category_name: string | null
|
||||
}
|
||||
|
||||
type WorkOrderTemplateProductState = {
|
||||
rule: WorkOrderTemplateProjectionRow
|
||||
productId: number
|
||||
hasActiveCost: boolean
|
||||
status: 'active' | 'archived'
|
||||
}
|
||||
|
||||
function runQuery<T extends QueryResultRow>(
|
||||
client: DbClient | undefined,
|
||||
text: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<QueryResult<T>> {
|
||||
return client ? client.query<T>(text, params) : query<T>(text, params)
|
||||
}
|
||||
|
||||
export function buildTemplateProductSku(ruleKey: string) {
|
||||
return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64)
|
||||
}
|
||||
|
||||
export function inferTemplateUnitName(productName: string) {
|
||||
const name = String(productName || '').trim()
|
||||
if (/钻石|颗/.test(name)) return '颗'
|
||||
if (/枪|把/.test(name)) return '把'
|
||||
if (/套/.test(name)) return '套'
|
||||
if (/密钥|秘钥/.test(name)) return '个'
|
||||
return '件'
|
||||
}
|
||||
|
||||
async function loadWorkOrderTemplateRule(ruleId: number, client?: DbClient) {
|
||||
const result = await runQuery<WorkOrderTemplateProjectionRow>(
|
||||
client,
|
||||
`
|
||||
SELECT wpr.*, wc.name AS category_name
|
||||
FROM work_product_rules wpr
|
||||
LEFT JOIN work_categories wc ON wc.id = wpr.category_id
|
||||
WHERE wpr.id = $1
|
||||
`,
|
||||
[ruleId],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
async function hasActiveWorkOrderCost(ruleId: number, client?: DbClient) {
|
||||
const result = await runQuery<{ active: boolean }>(
|
||||
client,
|
||||
`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM cost_versions v
|
||||
JOIN cost_items ci ON ci.id = v.cost_item_id
|
||||
WHERE ci.code = 'sys_work_order_reward'
|
||||
AND v.scope_type = 'rule'
|
||||
AND v.work_product_rule_id = $1
|
||||
AND v.status = 'active'
|
||||
AND v.amount_fen > 0
|
||||
AND v.effective_from <= NOW()
|
||||
AND (v.effective_to IS NULL OR v.effective_to > NOW())
|
||||
) AS active
|
||||
`,
|
||||
[ruleId],
|
||||
)
|
||||
return result.rows[0]?.active === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 接单模板的商品投影。
|
||||
*
|
||||
* 模板负责名称、分类和匹配配置;商品状态仍由运营在成本中心显式启停。
|
||||
* 这里只写商品身份和模板属性,不修改成本版本金额,避免模板入口成为价格真源。
|
||||
*/
|
||||
export async function syncWorkOrderTemplateProduct(
|
||||
ruleId: number,
|
||||
client?: DbClient,
|
||||
): Promise<WorkOrderTemplateProductState> {
|
||||
const rule = await loadWorkOrderTemplateRule(ruleId, client)
|
||||
if (!rule) {
|
||||
throw createHttpError(`接单模板不存在: ${ruleId}`, { statusCode: 404 })
|
||||
}
|
||||
const hasActiveCost = await hasActiveWorkOrderCost(ruleId, client)
|
||||
const now = nowIso()
|
||||
const attributes = {
|
||||
source: 'work_product_rule',
|
||||
ruleId: Number(rule.id),
|
||||
ruleKey: rule.rule_key,
|
||||
categoryId: rule.category_id ? Number(rule.category_id) : null,
|
||||
templateEnabled: rule.enabled === true,
|
||||
pricingMode: rule.pricing_mode === 'unit' ? 'unit' : 'fixed',
|
||||
rewardAmountFen: Number(rule.reward_amount || 0),
|
||||
unitPriceFen: Number(rule.unit_price_fen || 0),
|
||||
sharingEnabled: rule.sharing_enabled === true,
|
||||
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
|
||||
sharingAutoFromOrder: rule.sharing_auto_from_order === true,
|
||||
}
|
||||
const result = await runQuery<{ id: number; status: string }>(
|
||||
client,
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6)
|
||||
ON CONFLICT (canonical_sku) DO UPDATE SET
|
||||
canonical_name = EXCLUDED.canonical_name,
|
||||
category = EXCLUDED.category,
|
||||
unit_name = EXCLUDED.unit_name,
|
||||
attributes_json = COALESCE(product_catalog.attributes_json, '{}'::jsonb) || EXCLUDED.attributes_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id, status
|
||||
`,
|
||||
[
|
||||
buildTemplateProductSku(rule.rule_key),
|
||||
rule.product_name,
|
||||
rule.category_name || '',
|
||||
inferTemplateUnitName(rule.product_name),
|
||||
JSON.stringify(attributes),
|
||||
now,
|
||||
],
|
||||
)
|
||||
return {
|
||||
rule,
|
||||
productId: Number(result.rows[0]?.id || 0),
|
||||
hasActiveCost,
|
||||
status: result.rows[0]?.status === 'archived' ? 'archived' : 'active',
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertWorkOrderTemplateCanEnable(ruleId: number, client?: DbClient) {
|
||||
if (!(await hasActiveWorkOrderCost(ruleId, client))) {
|
||||
throw createHttpError('请先在商品与成本中心配置接单模板成本,再启用模板', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_template_cost_required',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveDeletedWorkOrderTemplateProduct(ruleKey: string, client?: DbClient) {
|
||||
await runQuery(
|
||||
client,
|
||||
`UPDATE product_catalog
|
||||
SET status = 'archived',
|
||||
attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb,
|
||||
updated_at = $2
|
||||
WHERE canonical_sku = $3 AND status = 'active'`,
|
||||
[
|
||||
JSON.stringify({ templateEnabled: false, templateDeleted: true }),
|
||||
nowIso(),
|
||||
buildTemplateProductSku(ruleKey),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getFulfillmentProfileByKey,
|
||||
upsertFulfillmentProfile,
|
||||
} from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js'
|
||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js'
|
||||
import {
|
||||
@@ -258,7 +258,7 @@ export async function createAffiliateDashMockClaim(
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const claimToken = await createTaskClaimToken(task.id, orderNo)
|
||||
await updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
@@ -274,7 +274,7 @@ export async function createAffiliateDashMockClaim(
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
claimToken: claimToken.token,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
claimUrl: buildOrderClaimUrl(orderNo),
|
||||
frontendBaseUrl: input.frontendBaseUrl,
|
||||
expectedUid: fallbackUid,
|
||||
step,
|
||||
@@ -633,7 +633,7 @@ function buildCreateResult(input: {
|
||||
taskNo: input.taskNo,
|
||||
claimToken: input.claimToken,
|
||||
claimUrl: input.claimUrl,
|
||||
frontendClaimUrl: buildFrontendClaimUrl(input.claimToken, input.frontendBaseUrl),
|
||||
frontendClaimUrl: buildOrderClaimUrl(input.orderNo, input.frontendBaseUrl || undefined),
|
||||
expectedUid: input.expectedUid,
|
||||
step: input.step,
|
||||
productName: input.productName,
|
||||
@@ -642,14 +642,6 @@ function buildCreateResult(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function buildFrontendClaimUrl(token: string, frontendBaseUrl?: unknown) {
|
||||
const base = String(frontendBaseUrl || '').trim()
|
||||
if (base) {
|
||||
return `${base.replace(/\/+$/, '')}/#/claim/${token}`
|
||||
}
|
||||
return `http://127.0.0.1/#/claim/${token}`
|
||||
}
|
||||
|
||||
function buildAffiliateDashTips(step: AffiliateDashMockStep, uid: string) {
|
||||
if (step === 'uid') {
|
||||
return [`打开领取链接,Step1 填 UID:${uid}(或自定义)`, '提交后进入绑定步,mock 会给出二维码']
|
||||
|
||||
@@ -110,12 +110,13 @@ test('P1:资金/积分写命令支持 requestId 幂等', async () => {
|
||||
assert.ok(migration.includes('uq_platform_credit_entries_account_request'))
|
||||
})
|
||||
|
||||
test('P1:成本版本追溯改价退休旧版本而不是覆盖金额', async () => {
|
||||
test('P1:成本改价退休旧版本且模板运行价格由成本中心同步', async () => {
|
||||
const [product, affiliate] = await Promise.all([
|
||||
read(new URL('../cost-center/product-catalog-service.ts', import.meta.url)),
|
||||
read(new URL('../cost-center/affiliate-product-service.ts', import.meta.url)),
|
||||
])
|
||||
assert.ok(product.includes('新增一个从 targetTime 起生效的版本'))
|
||||
assert.ok(product.includes('const costVersionId = await withTransaction'))
|
||||
assert.ok(product.includes('UPDATE work_product_rules'))
|
||||
assert.ok(product.includes("status = 'retired'"))
|
||||
assert.ok(affiliate.includes('新增追溯版本'))
|
||||
assert.ok(affiliate.includes("status = 'retired'"))
|
||||
|
||||
@@ -28,9 +28,10 @@ export async function runAffiliateFallback(
|
||||
order_item_id: number
|
||||
provider: string
|
||||
shop_id: string
|
||||
manual_sku_key: string | null
|
||||
internal_sku: string
|
||||
sku_name: string
|
||||
config_id: number | null
|
||||
config_product_id: number | null
|
||||
basis_date: string
|
||||
}>(
|
||||
`
|
||||
@@ -39,23 +40,35 @@ export async function runAffiliateFallback(
|
||||
oi.id AS order_item_id,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.manual_sku_key,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
|
||||
oi.sku_name,
|
||||
cfg.id AS config_id,
|
||||
cfg.canonical_product_id AS config_product_id,
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg
|
||||
ON cfg.sku_key = oi.manual_sku_key AND cfg.enabled
|
||||
ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
|
||||
AND cfg.enabled
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'affiliate_dash'
|
||||
AND oi.fulfillment_locked = FALSE
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id
|
||||
AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status = ANY($1::text[])
|
||||
SELECT 1 FROM internal_sku_channels channel
|
||||
WHERE channel.enabled = TRUE
|
||||
AND channel.channel = 'manual'
|
||||
AND channel.is_fallback = TRUE
|
||||
AND btrim(channel.internal_sku) = resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
)
|
||||
)
|
||||
AND (
|
||||
SELECT task.task_status
|
||||
FROM fulfillment_tasks task
|
||||
WHERE task.order_item_id = oi.id AND task.executor_key = 'affiliate_dash'
|
||||
ORDER BY task.unit_index DESC, task.updated_at DESC, task.id DESC
|
||||
LIMIT 1
|
||||
) = ANY($1::text[])
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id
|
||||
@@ -84,6 +97,7 @@ export async function runAffiliateFallback(
|
||||
channel_switch_reason = 'affiliate_failed',
|
||||
channel_switched_at = NOW(),
|
||||
fulfillment_config_id = COALESCE(fulfillment_config_id, $2),
|
||||
canonical_product_id = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND fulfillment_status = 'assigned'
|
||||
@@ -91,7 +105,7 @@ export async function runAffiliateFallback(
|
||||
AND fulfillment_locked = FALSE
|
||||
RETURNING id
|
||||
`,
|
||||
[row.id, row.config_id],
|
||||
[row.id, row.config_id, row.config_product_id],
|
||||
)
|
||||
if ((result.rowCount || 0) === 0) return false
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { syncDeliveryTasksForOrder } from '../order/delivery-task-service.js'
|
||||
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/sync-worker-orders-service.js'
|
||||
import { INACTIVE_CARRIER_TASK_STATUSES, type FulfillmentChannel } from './channel-types.js'
|
||||
import { applyIntakeChannelDecision } from './intake-channel-decision-service.js'
|
||||
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
|
||||
import { resolveInternalSkuKey } from './internal-sku-resolver.js'
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '{}'))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export type EnsureCarrierResult = {
|
||||
ready: boolean
|
||||
created: boolean
|
||||
reason: string
|
||||
message: string
|
||||
}
|
||||
|
||||
async function hasWorkOrder(orderItemId: number): Promise<boolean> {
|
||||
const result = await query<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM work_orders WHERE order_item_id = $1 AND status <> 'cancelled'
|
||||
) AS exists`,
|
||||
[orderItemId],
|
||||
)
|
||||
return result.rows[0]?.exists === true
|
||||
}
|
||||
|
||||
async function hasAffiliateTask(orderItemId: number): Promise<boolean> {
|
||||
const result = await query<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks
|
||||
WHERE order_item_id = $1 AND executor_key = 'affiliate_dash'
|
||||
AND task_status <> ALL($2::text[])
|
||||
) AS exists`,
|
||||
[orderItemId, INACTIVE_CARRIER_TASK_STATUSES],
|
||||
)
|
||||
return result.rows[0]?.exists === true
|
||||
}
|
||||
|
||||
function toResolvedItem(item: OrderItemRow): ResolvedFulfillmentItem {
|
||||
return {
|
||||
...item,
|
||||
itemId: String(item.id),
|
||||
externalItemId: String(item.id),
|
||||
externalSkuCode: item.sku_code,
|
||||
externalSkuName: item.sku_name,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
spec: parseObject(item.spec_json),
|
||||
snapshot: parseObject(item.item_snapshot_json),
|
||||
isConfigured: false,
|
||||
} as ResolvedFulfillmentItem
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等创建指定渠道的真实载体。
|
||||
*
|
||||
* 自动发货成功标准是存在 affiliate_dash 任务;接单成功标准是存在 work_order。
|
||||
* manual/voucher 不需要执行载体,直接视为 ready。
|
||||
*/
|
||||
export async function ensureCarrierForOrderItem(input: {
|
||||
order: OrderRow
|
||||
item: OrderItemRow
|
||||
channel: FulfillmentChannel
|
||||
}): Promise<EnsureCarrierResult> {
|
||||
const { order, item, channel } = input
|
||||
|
||||
if (channel === 'manual') {
|
||||
const config = await query<{ id: number; canonical_product_id: number | null }>(
|
||||
`SELECT id, canonical_product_id
|
||||
FROM manual_fulfillment_configs
|
||||
WHERE sku_key = resolve_internal_sku($1, $2, $3) AND enabled = TRUE
|
||||
LIMIT 1`,
|
||||
[item.manual_sku_key || '', item.sku_name, item.sku_code],
|
||||
)
|
||||
const row = config.rows[0]
|
||||
if (row) {
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_config_id = $2,
|
||||
canonical_product_id = COALESCE(canonical_product_id, $3),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[item.id, row.id, row.canonical_product_id],
|
||||
)
|
||||
return { ready: true, created: false, reason: 'manual_config_ready', message: '' }
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
created: false,
|
||||
reason: 'manual_config_missing',
|
||||
message: '人工渠道已选定,但缺少人工履约配置',
|
||||
}
|
||||
}
|
||||
|
||||
if (channel === 'third_party_voucher') {
|
||||
return { ready: true, created: false, reason: 'carrier_not_required', message: '' }
|
||||
}
|
||||
|
||||
if (channel === 'work_order') {
|
||||
if (await hasWorkOrder(Number(item.id))) {
|
||||
return { ready: true, created: false, reason: 'work_order_exists', message: '' }
|
||||
}
|
||||
const internalSku = resolveInternalSkuKey({
|
||||
manualSkuKey: item.manual_sku_key,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
spec: parseObject(item.spec_json),
|
||||
snapshot: parseObject(item.item_snapshot_json),
|
||||
})
|
||||
const channelConfig = await query<{ config_json: Record<string, unknown> }>(
|
||||
`SELECT config_json
|
||||
FROM internal_sku_channels
|
||||
WHERE btrim(internal_sku) = $1 AND channel = 'work_order' AND enabled = TRUE
|
||||
LIMIT 1`,
|
||||
[internalSku],
|
||||
)
|
||||
const preferredWorkRuleKey = String(
|
||||
channelConfig.rows[0]?.config_json?.workRuleKey || '',
|
||||
).trim()
|
||||
const snapshot = parseObject(item.item_snapshot_json)
|
||||
const classification = parseObject(snapshot.classification)
|
||||
const route = parseObject(snapshot.fulfillmentRoute)
|
||||
const workOrderSnapshot = {
|
||||
...snapshot,
|
||||
classification: { ...classification, channel: 'work_order' },
|
||||
fulfillmentRoute: {
|
||||
...route,
|
||||
selectedExecutorKey: '',
|
||||
reason: '内部 SKU 渠道指定接单平台',
|
||||
},
|
||||
}
|
||||
const workOrderItem = {
|
||||
...item,
|
||||
item_snapshot_json: workOrderSnapshot,
|
||||
} as OrderItemRow
|
||||
const result = await syncWorkerOrdersForSourceOrder(order, [workOrderItem], {
|
||||
source: 'carrier_creation',
|
||||
autoOnly: true,
|
||||
skipChannelAssign: true,
|
||||
...(preferredWorkRuleKey ? { preferredWorkRuleKey } : {}),
|
||||
})
|
||||
const ready = (await hasWorkOrder(Number(item.id))) || result.created.length > 0
|
||||
if (ready) {
|
||||
await query(
|
||||
`UPDATE order_items SET item_snapshot_json = $2::jsonb, updated_at = NOW() WHERE id = $1`,
|
||||
[item.id, JSON.stringify(workOrderSnapshot)],
|
||||
)
|
||||
}
|
||||
return {
|
||||
ready,
|
||||
created: result.created.length > 0,
|
||||
reason: ready ? 'work_order_created' : result.skipped[0]?.reason || 'work_order_unmatched',
|
||||
message: ready ? '' : '未匹配到可自动创建的接单模板',
|
||||
}
|
||||
}
|
||||
|
||||
if (await hasAffiliateTask(Number(item.id))) {
|
||||
return { ready: true, created: false, reason: 'affiliate_task_exists', message: '' }
|
||||
}
|
||||
|
||||
const resolved = await applyIntakeChannelDecision(
|
||||
toResolvedItem(item),
|
||||
{
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id,
|
||||
},
|
||||
{ preferredChannel: 'affiliate_dash' },
|
||||
)
|
||||
const snapshot = parseObject(resolved.snapshot)
|
||||
const route = parseObject(snapshot.fulfillmentRoute)
|
||||
if (route.selectedExecutorKey !== 'affiliate_dash') {
|
||||
return {
|
||||
ready: false,
|
||||
created: false,
|
||||
reason: 'affiliate_mapping_missing',
|
||||
message: '自动发货映射缺失',
|
||||
}
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET sku_code = $2, spec_json = $3::jsonb, item_snapshot_json = $4::jsonb, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[
|
||||
item.id,
|
||||
String(resolved.skuCode || item.sku_code),
|
||||
JSON.stringify(resolved.spec || {}),
|
||||
JSON.stringify(snapshot),
|
||||
],
|
||||
)
|
||||
const before = await hasAffiliateTask(Number(item.id))
|
||||
if (!before) {
|
||||
await syncDeliveryTasksForOrder(order, [
|
||||
{
|
||||
...item,
|
||||
sku_code: String(resolved.skuCode || item.sku_code),
|
||||
spec_json: resolved.spec || {},
|
||||
item_snapshot_json: snapshot,
|
||||
} as OrderItemRow,
|
||||
])
|
||||
}
|
||||
const ready = await hasAffiliateTask(Number(item.id))
|
||||
return {
|
||||
ready,
|
||||
created: ready && !before,
|
||||
reason: ready ? 'affiliate_task_created' : 'affiliate_task_planning_failed',
|
||||
message: ready ? '' : '自动发货任务规划失败',
|
||||
}
|
||||
}
|
||||
@@ -1,178 +1,340 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { syncDeliveryTasksForOrder } from '../order/delivery-task-service.js'
|
||||
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/sync-worker-orders-service.js'
|
||||
import { applyIntakeChannelDecision } from './intake-channel-decision-service.js'
|
||||
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
|
||||
import type { OrderItemRow } from '../../types/repository/rows.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import {
|
||||
FULFILLMENT_CHANNELS,
|
||||
INACTIVE_CARRIER_TASK_STATUSES,
|
||||
type FulfillmentChannel,
|
||||
} from './channel-types.js'
|
||||
import { ensureCarrierForOrderItem } from './carrier-creation-service.js'
|
||||
import {
|
||||
planInternalSkuChannels,
|
||||
type InternalSkuChannelRow,
|
||||
} from './internal-sku-channel-service.js'
|
||||
|
||||
type PendingCarrierItem = {
|
||||
type CarrierCandidate = {
|
||||
id: number
|
||||
order_id: number
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
quantity: number
|
||||
item_snapshot_json: unknown
|
||||
spec_json: unknown
|
||||
channel: 'affiliate_dash' | 'work_order'
|
||||
internal_sku: string
|
||||
channels: InternalSkuChannelRow[]
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '{}'))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
function normalizeChannels(value: unknown): InternalSkuChannelRow[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === 'object')
|
||||
.map((row) => ({
|
||||
id: Number(row.id || 0),
|
||||
internal_sku: String(row.internal_sku || '').trim(),
|
||||
channel: String(row.channel || '') as FulfillmentChannel,
|
||||
priority: Number(row.priority || 100),
|
||||
is_fallback: row.is_fallback === true,
|
||||
enabled: row.enabled !== false,
|
||||
canonical_product_id:
|
||||
row.canonical_product_id == null ? null : Number(row.canonical_product_id),
|
||||
config_json:
|
||||
row.config_json && typeof row.config_json === 'object'
|
||||
? (row.config_json as Record<string, unknown>)
|
||||
: {},
|
||||
source: String(row.source || ''),
|
||||
note: String(row.note || ''),
|
||||
created_by_admin_user_id:
|
||||
row.created_by_admin_user_id == null ? null : Number(row.created_by_admin_user_id),
|
||||
}))
|
||||
.filter((row) => FULFILLMENT_CHANNELS.includes(row.channel))
|
||||
}
|
||||
|
||||
/**
|
||||
* 为“内部 SKU 有渠道能力、但还没有载体”的 pending 单补载体。
|
||||
*
|
||||
* - 接单平台:走现有工单匹配器,能匹配模板就建工单;
|
||||
* - 自动发货:补 fulfillmentRoute/affiliateDash 后建任务;
|
||||
* - 补不出来的继续保留 no_carrier,等运营补模板/映射。
|
||||
*
|
||||
* 财务渠道判定不在本服务内做;调用方在补完载体后再跑一次 channel_assign。
|
||||
*/
|
||||
export async function reconcilePendingCarriers(input: { limit?: number } = {}): Promise<{
|
||||
scanned: number
|
||||
workOrdersCreated: number
|
||||
affiliateTasksCreated: number
|
||||
remaining: number
|
||||
}> {
|
||||
const limit = Math.min(500, Math.max(1, Math.trunc(Number(input.limit) || 200)))
|
||||
const rows = await query<PendingCarrierItem>(
|
||||
async function loadCandidateItems(input: {
|
||||
limit: number
|
||||
orderItemIds?: number[]
|
||||
assignedMissingCarrier?: boolean
|
||||
}): Promise<CarrierCandidate[]> {
|
||||
const ids = (input.orderItemIds || [])
|
||||
.map(Number)
|
||||
.filter((value) => Number.isInteger(value) && value > 0)
|
||||
const idFilter = ids.length > 0 ? 'AND oi.id = ANY($2::bigint[])' : ''
|
||||
const statusFilter = input.assignedMissingCarrier
|
||||
? `oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM work_orders wo
|
||||
WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')})
|
||||
)`
|
||||
: `oi.fulfillment_status = 'pending' AND oi.fulfillment_locked = FALSE`
|
||||
|
||||
const params: unknown[] = [input.limit]
|
||||
if (ids.length > 0) params.push(ids)
|
||||
const result = await query<CarrierCandidate>(
|
||||
`
|
||||
SELECT oi.id, oi.order_id, oi.sku_code, oi.sku_name, oi.quantity,
|
||||
oi.item_snapshot_json, oi.spec_json,
|
||||
cap.channel
|
||||
SELECT
|
||||
oi.id,
|
||||
oi.order_id,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
|
||||
capability.channels
|
||||
FROM order_items oi
|
||||
JOIN LATERAL (
|
||||
SELECT c.channel
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled = TRUE
|
||||
AND c.channel IN ('affiliate_dash', 'work_order')
|
||||
AND btrim(c.internal_sku) = COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), btrim(oi.sku_name))
|
||||
ORDER BY
|
||||
c.priority ASC,
|
||||
CASE c.channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
ELSE 9
|
||||
END,
|
||||
c.channel ASC
|
||||
LIMIT 1
|
||||
) cap ON TRUE
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND oi.fulfillment_locked = FALSE
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
)
|
||||
SELECT COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', channel.id,
|
||||
'internal_sku', channel.internal_sku,
|
||||
'channel', channel.channel,
|
||||
'priority', channel.priority,
|
||||
'is_fallback', channel.is_fallback,
|
||||
'enabled', channel.enabled,
|
||||
'canonical_product_id', channel.canonical_product_id,
|
||||
'config_json', channel.config_json,
|
||||
'source', channel.source
|
||||
,'note', channel.note
|
||||
,'created_by_admin_user_id', channel.created_by_admin_user_id
|
||||
)
|
||||
ORDER BY channel.priority, channel.channel
|
||||
),
|
||||
'[]'::jsonb
|
||||
) AS channels
|
||||
FROM internal_sku_channels channel
|
||||
WHERE channel.enabled = TRUE
|
||||
AND btrim(channel.internal_sku) = resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
)
|
||||
) capability ON jsonb_array_length(capability.channels) > 0
|
||||
WHERE ${statusFilter}
|
||||
${idFilter}
|
||||
ORDER BY oi.id
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit],
|
||||
params,
|
||||
)
|
||||
return result.rows.map((row) => ({ ...row, channels: normalizeChannels(row.channels) }))
|
||||
}
|
||||
|
||||
async function markPendingWithoutCarrier(input: {
|
||||
itemId: number
|
||||
internalSku: string
|
||||
attempts: Array<{ channel: FulfillmentChannel; reason: string; message: string }>
|
||||
}): Promise<void> {
|
||||
await query(
|
||||
`
|
||||
UPDATE order_items
|
||||
SET fulfillment_pending_reason = 'no_carrier',
|
||||
fulfillment_pending_detail = $2::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND fulfillment_status = 'pending'
|
||||
`,
|
||||
[
|
||||
input.itemId,
|
||||
JSON.stringify({
|
||||
internalSku: input.internalSku,
|
||||
attempts: input.attempts,
|
||||
}),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
async function selectSafeFallback(input: {
|
||||
orderItemId: number
|
||||
channel: FulfillmentChannel
|
||||
primaryChannel: FulfillmentChannel
|
||||
reason: string
|
||||
includeAssignedMissingCarrier: boolean
|
||||
}): Promise<boolean> {
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE order_items
|
||||
SET fulfillment_channel = $2,
|
||||
fulfillment_status = 'assigned',
|
||||
fulfillment_source = 'auto_mapping',
|
||||
fulfillment_reason = $3,
|
||||
channel_switch_reason = $3,
|
||||
channel_switched_at = NOW(),
|
||||
canonical_product_id = NULL,
|
||||
fulfillment_config_id = NULL,
|
||||
fulfillment_pending_reason = NULL,
|
||||
fulfillment_pending_detail = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND (
|
||||
(fulfillment_status = 'pending' AND fulfillment_locked = FALSE)
|
||||
OR (
|
||||
$4::boolean
|
||||
AND fulfillment_status = 'assigned'
|
||||
AND fulfillment_locked = FALSE
|
||||
AND fulfillment_source NOT IN ('operator_assigned', 'manual_confirm')
|
||||
)
|
||||
)
|
||||
`,
|
||||
[
|
||||
input.orderItemId,
|
||||
input.channel,
|
||||
`主渠道 ${input.primaryChannel} 无法创建载体:${input.reason}`,
|
||||
input.includeAssignedMissingCarrier,
|
||||
],
|
||||
)
|
||||
return (result.rowCount || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 internal_sku_channels 的明确优先级补载体。
|
||||
*
|
||||
* 主渠道是 affiliate/work_order 但载体暂时创建失败时,只有显式 is_fallback 的
|
||||
* manual/voucher 才能接管。所有结果都落 pending 明细,不再静默卡住。
|
||||
*/
|
||||
export async function reconcilePendingCarriers(
|
||||
input: {
|
||||
limit?: number
|
||||
orderItemIds?: number[]
|
||||
includeAssignedMissingCarrier?: boolean
|
||||
allowFallback?: boolean
|
||||
} = {},
|
||||
): Promise<{
|
||||
scanned: number
|
||||
workOrdersCreated: number
|
||||
affiliateTasksCreated: number
|
||||
fallbacksSelected: number
|
||||
failed: number
|
||||
remaining: number
|
||||
}> {
|
||||
const limit = Math.min(500, Math.max(1, Math.trunc(Number(input.limit) || 200)))
|
||||
const rows = await loadCandidateItems({
|
||||
limit,
|
||||
assignedMissingCarrier: input.includeAssignedMissingCarrier === true,
|
||||
...(input.orderItemIds ? { orderItemIds: input.orderItemIds } : {}),
|
||||
})
|
||||
|
||||
let workOrdersCreated = 0
|
||||
let affiliateTasksCreated = 0
|
||||
const byOrder = new Map<number, PendingCarrierItem[]>()
|
||||
for (const row of rows.rows) {
|
||||
let fallbacksSelected = 0
|
||||
let failed = 0
|
||||
const byOrder = new Map<number, CarrierCandidate[]>()
|
||||
for (const row of rows) {
|
||||
const list = byOrder.get(Number(row.order_id)) || []
|
||||
list.push(row)
|
||||
byOrder.set(Number(row.order_id), list)
|
||||
}
|
||||
|
||||
for (const [orderId, items] of byOrder) {
|
||||
for (const [orderId, candidates] of byOrder) {
|
||||
const order = await getOrderById(orderId)
|
||||
if (!order) continue
|
||||
const itemRows = await query<{
|
||||
id: number
|
||||
order_id: number
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
quantity: number
|
||||
item_snapshot_json: unknown
|
||||
spec_json: unknown
|
||||
}>(
|
||||
`SELECT id, order_id, sku_code, sku_name, quantity, item_snapshot_json, spec_json
|
||||
FROM order_items WHERE order_id = $1`,
|
||||
[orderId],
|
||||
)
|
||||
const itemRows = await query<OrderItemRow>(`SELECT * FROM order_items WHERE order_id = $1`, [
|
||||
orderId,
|
||||
])
|
||||
const itemById = new Map(itemRows.rows.map((item) => [Number(item.id), item]))
|
||||
|
||||
for (const target of items) {
|
||||
const item = itemById.get(Number(target.id))
|
||||
for (const candidate of candidates) {
|
||||
const item = itemById.get(Number(candidate.id))
|
||||
if (!item) continue
|
||||
|
||||
try {
|
||||
if (target.channel === 'work_order') {
|
||||
const result = await syncWorkerOrdersForSourceOrder(order, [item as never], {
|
||||
source: 'carrier_reconcile',
|
||||
autoOnly: true,
|
||||
})
|
||||
workOrdersCreated += result.created.length
|
||||
continue
|
||||
const assignedChannel = String(item.fulfillment_channel || '') as FulfillmentChannel
|
||||
const plan = planInternalSkuChannels(candidate.channels)
|
||||
const exactOnly =
|
||||
input.includeAssignedMissingCarrier === true &&
|
||||
(item.fulfillment_locked === true ||
|
||||
item.fulfillment_source === 'operator_assigned' ||
|
||||
item.fulfillment_source === 'manual_confirm')
|
||||
const attempts =
|
||||
input.includeAssignedMissingCarrier && exactOnly
|
||||
? FULFILLMENT_CHANNELS.includes(assignedChannel)
|
||||
? candidate.channels.filter((row) => row.channel === assignedChannel)
|
||||
: []
|
||||
: plan.attempts
|
||||
if (attempts.length === 0) {
|
||||
failed += 1
|
||||
if (!input.includeAssignedMissingCarrier) {
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_pending_reason = 'routing_conflict',
|
||||
fulfillment_pending_detail = $2::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND fulfillment_status = 'pending'`,
|
||||
[
|
||||
item.id,
|
||||
JSON.stringify({
|
||||
internalSku: candidate.internal_sku,
|
||||
channels: plan.conflicts.map((channel) => channel.channel),
|
||||
}),
|
||||
],
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
...item,
|
||||
itemId: String(item.id),
|
||||
externalItemId: String(item.id),
|
||||
externalSkuCode: item.sku_code,
|
||||
externalSkuName: item.sku_name,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
spec: parseObject(item.spec_json),
|
||||
snapshot: parseObject(item.item_snapshot_json),
|
||||
isConfigured: false,
|
||||
} as unknown as ResolvedFulfillmentItem
|
||||
const enriched = await applyIntakeChannelDecision(resolved, {
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id,
|
||||
})
|
||||
const snapshot = parseObject(enriched.snapshot)
|
||||
const route = parseObject(snapshot.fulfillmentRoute)
|
||||
if (route.selectedExecutorKey !== 'affiliate_dash') continue
|
||||
const failures: Array<{ channel: FulfillmentChannel; reason: string; message: string }> = []
|
||||
let resolved = false
|
||||
for (let index = 0; index < attempts.length; index += 1) {
|
||||
const attempt = attempts[index]!
|
||||
try {
|
||||
const carrier = await ensureCarrierForOrderItem({
|
||||
order,
|
||||
item,
|
||||
channel: attempt.channel,
|
||||
})
|
||||
if (!carrier.ready) {
|
||||
failures.push({
|
||||
channel: attempt.channel,
|
||||
reason: carrier.reason,
|
||||
message: carrier.message,
|
||||
})
|
||||
const canFallback =
|
||||
input.allowFallback !== false &&
|
||||
!exactOnly &&
|
||||
index === 0 &&
|
||||
(attempt.channel === 'affiliate_dash' || attempt.channel === 'work_order')
|
||||
if (canFallback) continue
|
||||
break
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET sku_code = $2,
|
||||
spec_json = $3,
|
||||
item_snapshot_json = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[
|
||||
item.id,
|
||||
String(enriched.skuCode || item.sku_code),
|
||||
JSON.stringify(enriched.spec || {}),
|
||||
JSON.stringify(snapshot),
|
||||
],
|
||||
)
|
||||
const tasks = await syncDeliveryTasksForOrder(order, [
|
||||
{
|
||||
...item,
|
||||
sku_code: String(enriched.skuCode || item.sku_code),
|
||||
spec_json: enriched.spec || {},
|
||||
item_snapshot_json: snapshot,
|
||||
} as never,
|
||||
])
|
||||
affiliateTasksCreated += tasks.length
|
||||
} catch (error) {
|
||||
logWarn('[carrier-reconciliation]', '补履约载体失败', {
|
||||
orderItemId: target.id,
|
||||
channel: target.channel,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
if (carrier.created && attempt.channel === 'work_order') workOrdersCreated += 1
|
||||
if (carrier.created && attempt.channel === 'affiliate_dash') affiliateTasksCreated += 1
|
||||
|
||||
const isFallbackAttempt = index > 0
|
||||
if (
|
||||
!exactOnly &&
|
||||
(attempt.channel === 'manual' ||
|
||||
attempt.channel === 'third_party_voucher' ||
|
||||
isFallbackAttempt)
|
||||
) {
|
||||
const primaryChannel = plan.primary?.channel || attempts[0]!.channel
|
||||
if (
|
||||
await selectSafeFallback({
|
||||
orderItemId: Number(item.id),
|
||||
channel: attempt.channel,
|
||||
primaryChannel,
|
||||
reason: failures[0]?.reason || 'fallback',
|
||||
includeAssignedMissingCarrier: input.includeAssignedMissingCarrier === true,
|
||||
})
|
||||
) {
|
||||
fallbacksSelected += 1
|
||||
}
|
||||
}
|
||||
resolved = true
|
||||
break
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
failures.push({ channel: attempt.channel, reason: 'exception', message })
|
||||
logWarn('[carrier-reconciliation]', '补履约载体失败', {
|
||||
orderItemId: item.id,
|
||||
channel: attempt.channel,
|
||||
error: message,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
failed += 1
|
||||
if (!input.includeAssignedMissingCarrier) {
|
||||
await markPendingWithoutCarrier({
|
||||
itemId: Number(item.id),
|
||||
internalSku: candidate.internal_sku,
|
||||
attempts: failures,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,15 +343,26 @@ export async function reconcilePendingCarriers(input: { limit?: number } = {}):
|
||||
`
|
||||
SELECT count(*)::int AS count
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND oi.fulfillment_pending_reason = 'no_carrier'
|
||||
WHERE oi.fulfillment_status IN ('pending', 'assigned')
|
||||
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM work_orders wo
|
||||
WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')})
|
||||
)
|
||||
`,
|
||||
)
|
||||
|
||||
return {
|
||||
scanned: rows.rows.length,
|
||||
scanned: rows.length,
|
||||
workOrdersCreated,
|
||||
affiliateTasksCreated,
|
||||
fallbacksSelected,
|
||||
failed,
|
||||
remaining: Number(remainingResult.rows[0]?.count || 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ChannelCandidateItem,
|
||||
type ChannelRule,
|
||||
} from './channel-assign-service.js'
|
||||
import type { InternalSkuChannelRow } from './internal-sku-channel-service.js'
|
||||
|
||||
function buildItem(patch: Partial<ChannelCandidateItem> = {}): ChannelCandidateItem {
|
||||
return {
|
||||
@@ -20,12 +21,29 @@ function buildItem(patch: Partial<ChannelCandidateItem> = {}): ChannelCandidateI
|
||||
has_work_order: false,
|
||||
has_affiliate_task: false,
|
||||
has_manual_task: false,
|
||||
capability_channel: null,
|
||||
channels: [],
|
||||
basis_date: '2026-10-31',
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
function buildChannel(patch: Partial<InternalSkuChannelRow> = {}): InternalSkuChannelRow {
|
||||
return {
|
||||
id: 1,
|
||||
internal_sku: '套装-测试',
|
||||
channel: 'manual',
|
||||
priority: 10,
|
||||
is_fallback: false,
|
||||
enabled: true,
|
||||
canonical_product_id: null,
|
||||
config_json: {},
|
||||
source: 'test',
|
||||
note: '',
|
||||
created_by_admin_user_id: null,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
function buildRule(patch: Partial<ChannelRule> = {}): ChannelRule {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -40,13 +58,13 @@ function buildRule(patch: Partial<ChannelRule> = {}): ChannelRule {
|
||||
}
|
||||
}
|
||||
|
||||
test('渠道判定:精确 SKU 规则优先于载体证据', () => {
|
||||
test('渠道判定:真实载体优先于冲突的旧规则', () => {
|
||||
const decision = resolveChannelDecision(buildItem({ has_work_order: true }), [
|
||||
buildRule({ channel: 'third_party_voucher', priority: 10 }),
|
||||
])
|
||||
|
||||
assert.equal(decision.channel, 'third_party_voucher')
|
||||
assert.equal(decision.source, 'auto_rule')
|
||||
assert.equal(decision.channel, 'work_order')
|
||||
assert.equal(decision.source, 'carrier_evidence')
|
||||
assert.equal(decision.conflict, false)
|
||||
})
|
||||
|
||||
@@ -79,19 +97,19 @@ test('渠道判定:同优先级多渠道判为冲突,不静默选择', () =>
|
||||
|
||||
test('渠道判定:不同优先级时取优先级更小的', () => {
|
||||
const decision = resolveChannelDecision(buildItem(), [
|
||||
buildRule({ id: 1, channel: 'affiliate_dash', priority: 60 }),
|
||||
buildRule({ id: 1, channel: 'third_party_voucher', priority: 60 }),
|
||||
buildRule({ id: 2, channel: 'manual', priority: 70 }),
|
||||
])
|
||||
|
||||
assert.equal(decision.channel, 'affiliate_dash')
|
||||
assert.equal(decision.channel, 'third_party_voucher')
|
||||
})
|
||||
|
||||
test('渠道判定:provider / shop 限定生效,空串表示不限', () => {
|
||||
const scoped = buildRule({ provider: 'pdd', shop_id: 'shop-b' })
|
||||
assert.equal(resolveChannelDecision(buildItem(), [scoped]).channel, null)
|
||||
|
||||
const wide = buildRule({ provider: '', shop_id: '' })
|
||||
assert.equal(resolveChannelDecision(buildItem(), [wide]).channel, 'affiliate_dash')
|
||||
const wide = buildRule({ provider: '', shop_id: '', channel: 'third_party_voucher' })
|
||||
assert.equal(resolveChannelDecision(buildItem(), [wide]).channel, 'third_party_voucher')
|
||||
})
|
||||
|
||||
test('渠道判定:内部 SKU 与 SKU 名称两种键都能命中', () => {
|
||||
@@ -125,23 +143,50 @@ test('渠道判定:停用的规则不参与', () => {
|
||||
|
||||
test('渠道判定:卡券/人工能力可兜底,自动发货/接单必须靠载体', () => {
|
||||
const voucher = resolveChannelDecision(
|
||||
buildItem({ capability_channel: 'third_party_voucher' }),
|
||||
buildItem({ channels: [buildChannel({ channel: 'third_party_voucher' })] }),
|
||||
[],
|
||||
)
|
||||
assert.equal(voucher.channel, 'third_party_voucher')
|
||||
assert.equal(voucher.source, 'auto_mapping')
|
||||
|
||||
const manual = resolveChannelDecision(buildItem({ capability_channel: 'manual' }), [])
|
||||
const manual = resolveChannelDecision(buildItem({ channels: [buildChannel()] }), [])
|
||||
assert.equal(manual.channel, 'manual')
|
||||
assert.equal(manual.source, 'auto_mapping')
|
||||
|
||||
const affiliate = resolveChannelDecision(buildItem({ capability_channel: 'affiliate_dash' }), [])
|
||||
const affiliate = resolveChannelDecision(
|
||||
buildItem({ channels: [buildChannel({ channel: 'affiliate_dash' })] }),
|
||||
[],
|
||||
)
|
||||
assert.equal(affiliate.channel, null, '自动发货能力没有任务时不能只改财务口径')
|
||||
|
||||
const workOrder = resolveChannelDecision(buildItem({ capability_channel: 'work_order' }), [])
|
||||
const workOrder = resolveChannelDecision(
|
||||
buildItem({ channels: [buildChannel({ channel: 'work_order' })] }),
|
||||
[],
|
||||
)
|
||||
assert.equal(workOrder.channel, null, '接单能力没有工单时不能只改财务口径')
|
||||
})
|
||||
|
||||
test('渠道判定:内部 SKU 配置优先于旧 sku_code 规则', () => {
|
||||
const decision = resolveChannelDecision(
|
||||
buildItem({ channels: [buildChannel({ channel: 'third_party_voucher' })] }),
|
||||
[buildRule({ channel: 'manual' })],
|
||||
)
|
||||
assert.equal(decision.channel, 'third_party_voucher')
|
||||
assert.equal(decision.source, 'auto_mapping')
|
||||
})
|
||||
|
||||
test('渠道判定:内部 SKU 同级多渠道判为冲突', () => {
|
||||
const decision = resolveChannelDecision(
|
||||
buildItem({
|
||||
channels: [buildChannel({ channel: 'work_order' }), buildChannel({ channel: 'manual' })],
|
||||
}),
|
||||
[],
|
||||
)
|
||||
assert.equal(decision.channel, null)
|
||||
assert.equal(decision.conflict, true)
|
||||
assert.deepEqual(decision.conflictChannels.sort(), ['manual', 'work_order'])
|
||||
})
|
||||
|
||||
test('渠道判定:人工任务作为人工载体证据', () => {
|
||||
const decision = resolveChannelDecision(buildItem({ has_manual_task: true }), [])
|
||||
assert.equal(decision.channel, 'manual')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,16 @@ export const FULFILLMENT_STATUSES = ['pending', 'assigned', 'archived'] as const
|
||||
|
||||
export type FulfillmentStatus = (typeof FULFILLMENT_STATUSES)[number]
|
||||
|
||||
/** 这些任务状态只保留审计价值,不再代表当前履约载体。 */
|
||||
export const INACTIVE_CARRIER_TASK_STATUSES = [
|
||||
'closed',
|
||||
'expired',
|
||||
'failed',
|
||||
'manual_review',
|
||||
'retry_pending',
|
||||
'cancelled',
|
||||
] as const
|
||||
|
||||
export const FULFILLMENT_SOURCES = [
|
||||
'auto_rule',
|
||||
'auto_mapping',
|
||||
@@ -27,6 +37,7 @@ export const FULFILLMENT_SOURCES = [
|
||||
'manual_confirm',
|
||||
'operator_assigned',
|
||||
'cutover_archive',
|
||||
'affiliate_fallback',
|
||||
] as const
|
||||
|
||||
export type FulfillmentSource = (typeof FULFILLMENT_SOURCES)[number] | ''
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl } from '../claim/claim-service.js'
|
||||
import { resolveTaskDeliveryLink } from './delivery-link-service.js'
|
||||
import type { TaskRow } from '../../types/repository/rows.js'
|
||||
|
||||
@@ -15,7 +15,7 @@ test('resolveTaskDeliveryLink 为 affiliate_dash 任务返回本站领取链接'
|
||||
)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
claimUrl: buildClaimUrl('affiliate-token'),
|
||||
claimUrl: buildOrderClaimUrl('2614900602069169'),
|
||||
expireTime: '2026-07-09T00:00:00.000Z',
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ test('resolveTaskDeliveryLink 兼容 claim_token 回落字段', async () => {
|
||||
)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
claimUrl: buildClaimUrl('legacy-token'),
|
||||
claimUrl: buildOrderClaimUrl('2614900602069169'),
|
||||
expireTime: '2026-07-09T08:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildClaimUrl } from '../../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import { prepareAffiliateDashTask } from '../affiliate-dash/index.js'
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ async function preparePaidTask(
|
||||
try {
|
||||
let workingTask = task
|
||||
if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) {
|
||||
const claimToken = await deps.createTaskClaimToken(task.id)
|
||||
const claimToken = await deps.createTaskClaimToken(task.id, task.platform_order_id)
|
||||
workingTask =
|
||||
(await deps.updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
@@ -64,7 +64,7 @@ async function resolveDeliveryLink(task: TaskRow): Promise<FulfillmentDeliveryLi
|
||||
}
|
||||
|
||||
return {
|
||||
claimUrl: buildClaimUrl(primaryToken),
|
||||
claimUrl: buildOrderClaimUrl(task.platform_order_id),
|
||||
expireTime,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ export type FulfillmentDeliveryLink = {
|
||||
|
||||
export type FulfillmentPrepareDeps = {
|
||||
updateTask: (taskId: number | string, patch: TaskUpdatePatch) => Promise<TaskRow | null>
|
||||
createTaskClaimToken: (taskId: number | string) => Promise<{
|
||||
createTaskClaimToken: (
|
||||
taskId: number | string,
|
||||
orderNo?: unknown,
|
||||
) => Promise<{
|
||||
token: string
|
||||
expired_at: string | null
|
||||
[key: string]: unknown
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 履约路由状态机集成测试(需要真实 PostgreSQL)。
|
||||
*
|
||||
* 运行:
|
||||
* FULFILLMENT_ROUTING_TEST_DATABASE_URL="$DATABASE_URL" \
|
||||
* npx tsx --test src/services/fulfillment/fulfillment-routing.integration.test.ts
|
||||
*
|
||||
* 未设置环境变量时跳过,普通 npm test 不依赖数据库。
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const TEST_DATABASE_URL = String(process.env.FULFILLMENT_ROUTING_TEST_DATABASE_URL || '').trim()
|
||||
|
||||
test(
|
||||
'运营指定渠道:先保证载体;支持安全切换;缺载体保持 pending',
|
||||
{ skip: !TEST_DATABASE_URL ? '未设置 FULFILLMENT_ROUTING_TEST_DATABASE_URL' : false },
|
||||
async () => {
|
||||
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||
const { closeDb, query } = await import('../../db/client.js')
|
||||
const {
|
||||
assignChannelByInternalSku,
|
||||
deleteChannelRules,
|
||||
ensureVoucherRuleForSku,
|
||||
listChannelRules,
|
||||
listChannelRulesPage,
|
||||
switchChannelForOrderItems,
|
||||
upsertChannelRule,
|
||||
} = await import('./channel-assign-service.js')
|
||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||
const shopId = `it-routing-${suffix}`
|
||||
const manualSku = `集成测试人工-${suffix}`
|
||||
const missingAffiliateSku = `集成测试自动-${suffix}`
|
||||
const voucherSku = `集成测试卡券-${suffix}`
|
||||
const orderIds: number[] = []
|
||||
const orderItemIds: number[] = []
|
||||
const internalSkus = [manualSku, missingAffiliateSku, voucherSku]
|
||||
let generatedVoucherProductId: number | null = null
|
||||
|
||||
async function createPendingOrderItem(sku: string) {
|
||||
const order = await query<{ id: number }>(
|
||||
`INSERT INTO orders (
|
||||
provider, platform, shop_id, platform_order_id, order_status, pay_status,
|
||||
total_amount, paid_at, created_at, updated_at
|
||||
) VALUES (
|
||||
'91kaquan', 'kuaishou-91', $1, $2, 'paid', 'paid', 1000, NOW(), NOW(), NOW()
|
||||
) RETURNING id`,
|
||||
[shopId, `IT-ROUTING-${suffix}-${orderItemIds.length + 1}`],
|
||||
)
|
||||
const orderId = Number(order.rows[0]!.id)
|
||||
orderIds.push(orderId)
|
||||
const item = await query<{ id: number }>(
|
||||
`INSERT INTO order_items (
|
||||
order_id, sku_code, sku_name, quantity, spec_json,
|
||||
revenue_amount_fen, revenue_basis, created_at, updated_at
|
||||
) VALUES ($1, $2, $2, 1, $3::jsonb, 1000, 'order_total', NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[orderId, sku, JSON.stringify({ skuNick: sku })],
|
||||
)
|
||||
const orderItemId = Number(item.rows[0]!.id)
|
||||
orderItemIds.push(orderItemId)
|
||||
return orderItemId
|
||||
}
|
||||
|
||||
try {
|
||||
const manualItemId = await createPendingOrderItem(manualSku)
|
||||
await query(
|
||||
`INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at
|
||||
) VALUES ($1, 'nanhang', 0, 'variable', TRUE, '集成测试', NOW(), NOW())`,
|
||||
[manualSku],
|
||||
)
|
||||
|
||||
const manualAssigned = await assignChannelByInternalSku({
|
||||
provider: '91kaquan',
|
||||
shopId,
|
||||
internalSku: manualSku,
|
||||
channel: 'manual',
|
||||
adminUserId: null,
|
||||
note: '集成测试人工',
|
||||
})
|
||||
assert.equal(manualAssigned.updated, 1)
|
||||
assert.equal(manualAssigned.carrierFailed, 0)
|
||||
const manualItem = await query<{
|
||||
fulfillment_status: string
|
||||
fulfillment_channel: string
|
||||
fulfillment_config_id: number | null
|
||||
}>(
|
||||
`SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id
|
||||
FROM order_items WHERE id = $1`,
|
||||
[manualItemId],
|
||||
)
|
||||
assert.equal(manualItem.rows[0]?.fulfillment_status, 'assigned')
|
||||
assert.equal(manualItem.rows[0]?.fulfillment_channel, 'manual')
|
||||
assert.ok(Number(manualItem.rows[0]?.fulfillment_config_id) > 0)
|
||||
|
||||
const switched = await switchChannelForOrderItems({
|
||||
orderItemIds: [manualItemId],
|
||||
channel: 'third_party_voucher',
|
||||
adminUserId: null,
|
||||
note: '集成测试切换',
|
||||
})
|
||||
assert.equal(switched.switched, 1)
|
||||
assert.equal(switched.failed, 0)
|
||||
const switchedItem = await query<{
|
||||
fulfillment_status: string
|
||||
fulfillment_channel: string
|
||||
original_channel: string | null
|
||||
canonical_product_id: number | null
|
||||
canonical_sku: string | null
|
||||
}>(
|
||||
`SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel,
|
||||
oi.canonical_product_id, product.canonical_sku
|
||||
FROM order_items oi
|
||||
LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id
|
||||
WHERE oi.id = $1`,
|
||||
[manualItemId],
|
||||
)
|
||||
assert.equal(switchedItem.rows[0]?.fulfillment_status, 'assigned')
|
||||
assert.equal(switchedItem.rows[0]?.fulfillment_channel, 'third_party_voucher')
|
||||
assert.equal(switchedItem.rows[0]?.original_channel, 'manual')
|
||||
assert.ok(switchedItem.rows[0]?.canonical_sku?.startsWith('tpv:'))
|
||||
generatedVoucherProductId = Number(switchedItem.rows[0]?.canonical_product_id || 0) || null
|
||||
|
||||
const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku)
|
||||
const affiliateFailed = await assignChannelByInternalSku({
|
||||
provider: '91kaquan',
|
||||
shopId,
|
||||
internalSku: missingAffiliateSku,
|
||||
channel: 'affiliate_dash',
|
||||
adminUserId: null,
|
||||
note: '集成测试缺映射',
|
||||
})
|
||||
assert.equal(affiliateFailed.updated, 0)
|
||||
assert.equal(affiliateFailed.carrierFailed, 1)
|
||||
const pendingItem = await query<{
|
||||
fulfillment_status: string
|
||||
fulfillment_channel: string | null
|
||||
fulfillment_pending_reason: string | null
|
||||
}>(
|
||||
`SELECT fulfillment_status, fulfillment_channel, fulfillment_pending_reason
|
||||
FROM order_items WHERE id = $1`,
|
||||
[missingAffiliateItemId],
|
||||
)
|
||||
assert.equal(pendingItem.rows[0]?.fulfillment_status, 'pending')
|
||||
assert.equal(pendingItem.rows[0]?.fulfillment_channel, null)
|
||||
assert.equal(pendingItem.rows[0]?.fulfillment_pending_reason, 'no_carrier')
|
||||
|
||||
await ensureVoucherRuleForSku(voucherSku)
|
||||
const voucherChannel = await query<{ channel: string; source: string }>(
|
||||
`SELECT channel, source FROM internal_sku_channels
|
||||
WHERE internal_sku = $1 ORDER BY channel`,
|
||||
[voucherSku],
|
||||
)
|
||||
assert.deepEqual(voucherChannel.rows, [{ channel: 'third_party_voucher', source: 'voucher' }])
|
||||
const legacyVoucherRules = await query<{ count: number }>(
|
||||
`SELECT count(*)::int AS count FROM sku_channel_rules
|
||||
WHERE match_key = $1`,
|
||||
[voucherSku],
|
||||
)
|
||||
assert.equal(legacyVoucherRules.rows[0]?.count, 0)
|
||||
const savedRule = await upsertChannelRule({
|
||||
matchKeyType: 'internal_sku',
|
||||
matchKey: voucherSku,
|
||||
channel: 'third_party_voucher',
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
note: '集成测试统一渠道',
|
||||
adminUserId: null,
|
||||
})
|
||||
const listedRules = await listChannelRules({ channel: 'third_party_voucher', enabled: true })
|
||||
assert.ok(
|
||||
listedRules.some((rule) => rule.id === savedRule.id && rule.match_key === voucherSku),
|
||||
)
|
||||
const firstRulePage = await listChannelRulesPage({
|
||||
channel: 'third_party_voucher',
|
||||
enabled: true,
|
||||
page: 1,
|
||||
pageSize: 2,
|
||||
})
|
||||
assert.equal(firstRulePage.page, 1)
|
||||
assert.equal(firstRulePage.pageSize, 2)
|
||||
assert.ok(firstRulePage.items.length <= 2)
|
||||
assert.equal(firstRulePage.total, listedRules.length)
|
||||
const deletedRules = await deleteChannelRules([savedRule.id])
|
||||
assert.equal(deletedRules.deletedCount, 1)
|
||||
assert.deepEqual(deletedRules.deletedIds, [savedRule.id])
|
||||
} finally {
|
||||
if (orderItemIds.length > 0) {
|
||||
await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [
|
||||
orderItemIds,
|
||||
])
|
||||
await query(`DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`, [
|
||||
orderItemIds,
|
||||
])
|
||||
await query(`DELETE FROM order_items WHERE id = ANY($1::bigint[])`, [orderItemIds])
|
||||
}
|
||||
if (orderIds.length > 0) {
|
||||
await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds])
|
||||
}
|
||||
await query(`DELETE FROM cost_exceptions WHERE scope_key LIKE $1`, [`%${suffix}%`])
|
||||
await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1`, [manualSku])
|
||||
await query(`DELETE FROM internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [
|
||||
internalSkus,
|
||||
])
|
||||
await query(
|
||||
`DELETE FROM sku_channel_rules
|
||||
WHERE provider = '91kaquan' AND shop_id = $1 AND match_key = ANY($2::text[])`,
|
||||
[shopId, internalSkus],
|
||||
)
|
||||
if (generatedVoucherProductId) {
|
||||
await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [
|
||||
generatedVoucherProductId,
|
||||
])
|
||||
await query(`DELETE FROM cost_snapshots WHERE canonical_product_id = $1`, [
|
||||
generatedVoucherProductId,
|
||||
])
|
||||
await query(`DELETE FROM channel_product_mappings WHERE canonical_product_id = $1`, [
|
||||
generatedVoucherProductId,
|
||||
])
|
||||
await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [
|
||||
generatedVoucherProductId,
|
||||
])
|
||||
await query(`DELETE FROM product_catalog WHERE id = $1 AND canonical_sku = $2`, [
|
||||
generatedVoucherProductId,
|
||||
`tpv:${manualSku}`,
|
||||
])
|
||||
}
|
||||
await closeDb()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1,10 +1,9 @@
|
||||
import {
|
||||
listInternalSkuChannels,
|
||||
type InternalSkuChannelRow,
|
||||
} from './internal-sku-channel-service.js'
|
||||
import { listInternalSkuChannels, planInternalSkuChannels } from './internal-sku-channel-service.js'
|
||||
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
|
||||
import { matchAffiliateDashSkuByInternalSku } from './product-resolution-service.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import type { FulfillmentChannel } from './channel-types.js'
|
||||
import { resolveInternalSkuKey } from './internal-sku-resolver.js'
|
||||
|
||||
type IntakeChannelDecisionInput = {
|
||||
provider?: string
|
||||
@@ -18,14 +17,6 @@ function parseObject(value: unknown): Record<string, unknown> {
|
||||
: {}
|
||||
}
|
||||
|
||||
function resolveInternalSku(item: ResolvedFulfillmentItem): string {
|
||||
const snapshot = parseObject(item.snapshot)
|
||||
const spec = parseObject(item.spec)
|
||||
return String(
|
||||
snapshot.internalSku || spec.skuNick || spec.outSkuSn || item.skuCode || item.skuName || '',
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单入口读取内部 SKU 渠道决策。
|
||||
*
|
||||
@@ -36,13 +27,14 @@ function resolveInternalSku(item: ResolvedFulfillmentItem): string {
|
||||
export async function applyIntakeChannelDecision(
|
||||
item: ResolvedFulfillmentItem,
|
||||
_input: IntakeChannelDecisionInput,
|
||||
options: { preferredChannel?: FulfillmentChannel } = {},
|
||||
): Promise<ResolvedFulfillmentItem> {
|
||||
const internalSku = resolveInternalSku(item)
|
||||
const internalSku = resolveInternalSkuKey(item)
|
||||
if (!internalSku) return item
|
||||
|
||||
let channels: InternalSkuChannelRow[] = []
|
||||
let plan
|
||||
try {
|
||||
channels = await listInternalSkuChannels(internalSku)
|
||||
plan = planInternalSkuChannels(await listInternalSkuChannels(internalSku))
|
||||
} catch (error) {
|
||||
// 单测/旧库没有 118 迁移时不影响下单主流程,生产由迁移保证。
|
||||
logWarn('[intake-channel-decision]', '读取内部 SKU 渠道失败', {
|
||||
@@ -51,14 +43,32 @@ export async function applyIntakeChannelDecision(
|
||||
})
|
||||
return item
|
||||
}
|
||||
if (channels.length === 0) return item
|
||||
if (plan.conflicts.length > 0) {
|
||||
return {
|
||||
...item,
|
||||
snapshot: {
|
||||
...parseObject(item.snapshot),
|
||||
internalSku,
|
||||
internalSkuChannelReason: 'routing_conflict',
|
||||
internalSkuConflictChannels: plan.conflicts.map((channel) => channel.channel),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const preferred = options.preferredChannel
|
||||
? plan.attempts.find((candidate) => candidate.channel === options.preferredChannel) ||
|
||||
(options.preferredChannel === 'manual' || options.preferredChannel === 'third_party_voucher'
|
||||
? null
|
||||
: undefined)
|
||||
: undefined
|
||||
const primary = preferred === null ? null : preferred || plan.primary
|
||||
if (!primary) return item
|
||||
|
||||
const snapshot = parseObject(item.snapshot)
|
||||
const route = parseObject(snapshot.fulfillmentRoute)
|
||||
const selected = String(route.selectedExecutorKey || '').trim()
|
||||
// 路由配置未命中时会默认落 manual_dispatch;这不是运营显式规则,不能覆盖内部 SKU 能力。
|
||||
const isDefaultManualFallback = selected === 'manual_dispatch' && !route.matchedRule
|
||||
const primary = channels[0]!
|
||||
const nextSnapshot: Record<string, unknown> = {
|
||||
...snapshot,
|
||||
internalSku: String(snapshot.internalSku || internalSku),
|
||||
@@ -68,9 +78,14 @@ export async function applyIntakeChannelDecision(
|
||||
|
||||
const canOverrideDefaultManual = !selected || isDefaultManualFallback
|
||||
const existingAffiliateSku = String(parseObject(snapshot.affiliateDash).sku || '').trim()
|
||||
const configuredAffiliateSku = String(primary.config_json.affiliateSku || '').trim()
|
||||
|
||||
if (primary.channel === 'affiliate_dash' || selected === 'affiliate_dash') {
|
||||
const dashSku = existingAffiliateSku || matchAffiliateDashSkuByInternalSku(internalSku)
|
||||
const desiredChannel = options.preferredChannel || primary.channel
|
||||
if (desiredChannel === 'affiliate_dash' || selected === 'affiliate_dash') {
|
||||
const dashSku =
|
||||
existingAffiliateSku ||
|
||||
configuredAffiliateSku ||
|
||||
matchAffiliateDashSkuByInternalSku(internalSku)
|
||||
if (dashSku) {
|
||||
if (canOverrideDefaultManual || selected === 'affiliate_dash') {
|
||||
nextSnapshot.fulfillmentRoute = {
|
||||
@@ -109,17 +124,27 @@ export async function applyIntakeChannelDecision(
|
||||
|
||||
// 内部能力是接单/卡券,但路由只是默认人工兜底:清掉人工任务,交给工单或财务能力兜底。
|
||||
if (
|
||||
(primary.channel === 'work_order' || primary.channel === 'third_party_voucher') &&
|
||||
(desiredChannel === 'work_order' || desiredChannel === 'third_party_voucher') &&
|
||||
isDefaultManualFallback
|
||||
) {
|
||||
nextSnapshot.fulfillmentRoute = {
|
||||
...route,
|
||||
selectedExecutorKey: '',
|
||||
reason: `内部 SKU 渠道能力为 ${primary.channel},不使用默认人工兜底`,
|
||||
reason: `内部 SKU 渠道能力为 ${desiredChannel},不使用默认人工兜底`,
|
||||
matchedRule: null,
|
||||
}
|
||||
return { ...item, isConfigured: false, snapshot: nextSnapshot }
|
||||
}
|
||||
|
||||
if (desiredChannel === 'manual' && isDefaultManualFallback) {
|
||||
nextSnapshot.fulfillmentRoute = {
|
||||
...route,
|
||||
selectedExecutorKey: 'manual_dispatch',
|
||||
reason: '内部 SKU 渠道能力指定人工客服',
|
||||
matchedRule: null,
|
||||
}
|
||||
return { ...item, isConfigured: true, snapshot: nextSnapshot }
|
||||
}
|
||||
|
||||
return { ...item, snapshot: nextSnapshot }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
planInternalSkuChannels,
|
||||
type InternalSkuChannelRow,
|
||||
} from './internal-sku-channel-service.js'
|
||||
|
||||
function channel(patch: Partial<InternalSkuChannelRow>): InternalSkuChannelRow {
|
||||
return {
|
||||
id: 1,
|
||||
internal_sku: '内部SKU',
|
||||
channel: 'manual',
|
||||
priority: 10,
|
||||
is_fallback: false,
|
||||
enabled: true,
|
||||
canonical_product_id: null,
|
||||
config_json: {},
|
||||
source: 'test',
|
||||
note: '',
|
||||
created_by_admin_user_id: null,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
test('渠道计划:主渠道失败后按优先级使用显式兜底', () => {
|
||||
const plan = planInternalSkuChannels([
|
||||
channel({ channel: 'manual', priority: 90, is_fallback: true }),
|
||||
channel({ channel: 'affiliate_dash', priority: 10 }),
|
||||
])
|
||||
assert.equal(plan.primary?.channel, 'affiliate_dash')
|
||||
assert.deepEqual(
|
||||
plan.attempts.map((item) => item.channel),
|
||||
['affiliate_dash', 'manual'],
|
||||
)
|
||||
})
|
||||
|
||||
test('渠道计划:同级多主渠道是冲突,不能静默按字母选一个', () => {
|
||||
const plan = planInternalSkuChannels([
|
||||
channel({ channel: 'manual', priority: 10 }),
|
||||
channel({ channel: 'work_order', priority: 10 }),
|
||||
])
|
||||
assert.equal(plan.primary, null)
|
||||
assert.deepEqual(plan.conflicts.map((item) => item.channel).sort(), ['manual', 'work_order'])
|
||||
})
|
||||
|
||||
test('渠道计划:非兜底渠道即使优先级数值更大也压过兜底渠道', () => {
|
||||
const plan = planInternalSkuChannels([
|
||||
channel({ channel: 'manual', priority: 1, is_fallback: true }),
|
||||
channel({ channel: 'work_order', priority: 50 }),
|
||||
])
|
||||
assert.equal(plan.primary?.channel, 'work_order')
|
||||
assert.equal(plan.fallbacks[0]?.channel, 'manual')
|
||||
})
|
||||
|
||||
test('渠道计划:仅有一个兜底渠道时仍可作为实际主渠道', () => {
|
||||
const plan = planInternalSkuChannels([
|
||||
channel({ channel: 'manual', priority: 90, is_fallback: true }),
|
||||
])
|
||||
assert.equal(plan.primary?.channel, 'manual')
|
||||
assert.deepEqual(
|
||||
plan.attempts.map((item) => item.channel),
|
||||
['manual'],
|
||||
)
|
||||
})
|
||||
@@ -1,14 +1,26 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import type { FulfillmentChannel } from './channel-types.js'
|
||||
import { compareFulfillmentChannel } from './internal-sku-resolver.js'
|
||||
|
||||
export type InternalSkuChannelRow = {
|
||||
id: number
|
||||
internal_sku: string
|
||||
channel: FulfillmentChannel
|
||||
priority: number
|
||||
is_fallback: boolean
|
||||
enabled: boolean
|
||||
canonical_product_id: number | null
|
||||
config_json: Record<string, unknown>
|
||||
source: string
|
||||
note: string
|
||||
created_by_admin_user_id: number | null
|
||||
}
|
||||
|
||||
export type InternalSkuChannelPlan = {
|
||||
primary: InternalSkuChannelRow | null
|
||||
fallbacks: InternalSkuChannelRow[]
|
||||
attempts: InternalSkuChannelRow[]
|
||||
conflicts: InternalSkuChannelRow[]
|
||||
}
|
||||
|
||||
export async function listInternalSkuChannels(
|
||||
@@ -18,18 +30,12 @@ export async function listInternalSkuChannels(
|
||||
if (!key) return []
|
||||
const result = await query<InternalSkuChannelRow>(
|
||||
`
|
||||
SELECT internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source
|
||||
SELECT id, internal_sku, channel, priority, is_fallback, enabled, canonical_product_id,
|
||||
config_json, source, note, created_by_admin_user_id
|
||||
FROM internal_sku_channels
|
||||
WHERE btrim(internal_sku) = $1 AND enabled = TRUE
|
||||
ORDER BY
|
||||
priority ASC,
|
||||
CASE channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
WHEN 'third_party_voucher' THEN 3
|
||||
WHEN 'manual' THEN 4
|
||||
ELSE 9
|
||||
END,
|
||||
channel ASC
|
||||
`,
|
||||
[key],
|
||||
@@ -37,15 +43,44 @@ export async function listInternalSkuChannels(
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/** 财务侧可直接兜底的渠道:卡券/人工不要求新建履约任务。 */
|
||||
export async function resolveInternalSkuFallbackChannel(
|
||||
internalSku: string,
|
||||
): Promise<FulfillmentChannel | null> {
|
||||
const channels = await listInternalSkuChannels(internalSku)
|
||||
const safe = channels.find(
|
||||
(row) => row.channel === 'third_party_voucher' || row.channel === 'manual',
|
||||
/**
|
||||
* 渠道计划:同一时间只允许一个主渠道;同级多渠道视为配置冲突。
|
||||
* is_fallback 渠道只在主渠道不可用或失败后按 priority 顺序尝试。
|
||||
*/
|
||||
export function planInternalSkuChannels(input: InternalSkuChannelRow[]): InternalSkuChannelPlan {
|
||||
const channels = input
|
||||
.filter((row) => row.enabled)
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.priority - b.priority ||
|
||||
Number(a.is_fallback) - Number(b.is_fallback) ||
|
||||
compareFulfillmentChannel(a.channel, b.channel),
|
||||
)
|
||||
|
||||
const explicitPrimary = channels.filter((row) => !row.is_fallback)
|
||||
const primaryPool = explicitPrimary.length > 0 ? explicitPrimary : channels
|
||||
if (primaryPool.length === 0) {
|
||||
return { primary: null, fallbacks: [], attempts: [], conflicts: [] }
|
||||
}
|
||||
|
||||
const topPriority = Math.min(...primaryPool.map((row) => row.priority))
|
||||
const top = primaryPool.filter((row) => row.priority === topPriority)
|
||||
const distinctTop = top.filter(
|
||||
(row, index) => top.findIndex((candidate) => candidate.channel === row.channel) === index,
|
||||
)
|
||||
return safe?.channel || null
|
||||
if (distinctTop.length > 1) {
|
||||
return { primary: null, fallbacks: [], attempts: [], conflicts: distinctTop }
|
||||
}
|
||||
|
||||
const primary = distinctTop[0] || null
|
||||
const fallbacks = channels.filter((row) => row.channel !== primary?.channel && row.is_fallback)
|
||||
return {
|
||||
primary,
|
||||
fallbacks,
|
||||
attempts: primary ? [primary, ...fallbacks] : [],
|
||||
conflicts: [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertInternalSkuChannel(input: {
|
||||
@@ -53,31 +88,136 @@ export async function upsertInternalSkuChannel(input: {
|
||||
channel: FulfillmentChannel
|
||||
priority?: number
|
||||
isFallback?: boolean
|
||||
enabled?: boolean
|
||||
canonicalProductId?: number | null
|
||||
configJson?: Record<string, unknown> | undefined
|
||||
source?: string
|
||||
note?: string | undefined
|
||||
adminUserId?: number | null | undefined
|
||||
replacePriority?: boolean
|
||||
}): Promise<number> {
|
||||
const internalSku = String(input.internalSku || '').trim()
|
||||
if (!internalSku) return 0
|
||||
return withTransaction(async (client) => {
|
||||
if (input.enabled !== false && input.isFallback !== true) {
|
||||
await client.query(
|
||||
`UPDATE internal_sku_channels
|
||||
SET is_fallback = TRUE,
|
||||
priority = GREATEST(priority, 90),
|
||||
updated_at = NOW()
|
||||
WHERE btrim(internal_sku) = $1
|
||||
AND channel <> $2
|
||||
AND enabled = TRUE
|
||||
AND is_fallback = FALSE`,
|
||||
[internalSku, input.channel],
|
||||
)
|
||||
}
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled,
|
||||
canonical_product_id, config_json, source, note, created_by_admin_user_id,
|
||||
created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, NOW(), NOW())
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = CASE
|
||||
WHEN $11::boolean THEN EXCLUDED.priority
|
||||
ELSE LEAST(internal_sku_channels.priority, EXCLUDED.priority)
|
||||
END,
|
||||
is_fallback = EXCLUDED.is_fallback,
|
||||
enabled = EXCLUDED.enabled,
|
||||
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
|
||||
config_json = internal_sku_channels.config_json || EXCLUDED.config_json,
|
||||
source = EXCLUDED.source,
|
||||
note = CASE WHEN EXCLUDED.note <> '' THEN EXCLUDED.note ELSE internal_sku_channels.note END,
|
||||
created_by_admin_user_id = COALESCE(EXCLUDED.created_by_admin_user_id, internal_sku_channels.created_by_admin_user_id),
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
internalSku,
|
||||
input.channel,
|
||||
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
|
||||
input.isFallback === true,
|
||||
input.enabled !== false,
|
||||
input.canonicalProductId ?? null,
|
||||
JSON.stringify(input.configJson || {}),
|
||||
String(input.source || 'auto').trim() || 'auto',
|
||||
String(input.note || '').slice(0, 300),
|
||||
input.adminUserId ?? null,
|
||||
input.replacePriority === true,
|
||||
],
|
||||
)
|
||||
return Number(result.rows[0]?.id || 0)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营指定一个主渠道。其他渠道保留为显式兜底,避免下一次又被同级优先级抢占。
|
||||
*/
|
||||
export async function setInternalSkuPrimaryChannel(input: {
|
||||
internalSku: string
|
||||
channel: FulfillmentChannel
|
||||
canonicalProductId?: number | null
|
||||
source?: string
|
||||
configJson?: Record<string, unknown> | undefined
|
||||
note?: string | undefined
|
||||
adminUserId?: number | null
|
||||
}): Promise<void> {
|
||||
const internalSku = String(input.internalSku || '').trim()
|
||||
if (!internalSku) return
|
||||
await query(
|
||||
|
||||
await upsertInternalSkuChannel({
|
||||
internalSku,
|
||||
channel: input.channel,
|
||||
priority: 10,
|
||||
isFallback: false,
|
||||
enabled: true,
|
||||
canonicalProductId: input.canonicalProductId ?? null,
|
||||
configJson: input.configJson,
|
||||
source: input.source || 'operator',
|
||||
note: input.note,
|
||||
adminUserId: input.adminUserId,
|
||||
replacePriority: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function disableInternalSkuChannel(input: {
|
||||
internalSku: string
|
||||
channel: FulfillmentChannel
|
||||
}): Promise<number> {
|
||||
const result = await query(
|
||||
`
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled,
|
||||
canonical_product_id, source, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = internal_sku_channels.is_fallback AND EXCLUDED.is_fallback,
|
||||
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
|
||||
enabled = TRUE,
|
||||
updated_at = NOW()
|
||||
UPDATE internal_sku_channels
|
||||
SET enabled = FALSE, updated_at = NOW()
|
||||
WHERE btrim(internal_sku) = $1 AND channel = $2
|
||||
`,
|
||||
[
|
||||
internalSku,
|
||||
input.channel,
|
||||
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
|
||||
input.isFallback === true,
|
||||
input.canonicalProductId ?? null,
|
||||
String(input.source || 'auto').trim() || 'auto',
|
||||
],
|
||||
[String(input.internalSku || '').trim(), input.channel],
|
||||
)
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
export async function deleteInternalSkuChannelById(channelId: number): Promise<boolean> {
|
||||
const id = Math.trunc(Number(channelId) || 0)
|
||||
if (id <= 0) return false
|
||||
const result = await query(`DELETE FROM internal_sku_channels WHERE id = $1`, [id])
|
||||
return (result.rowCount || 0) > 0
|
||||
}
|
||||
|
||||
export async function deleteInternalSkuChannelsByIds(idsInput: number[]): Promise<{
|
||||
deletedIds: number[]
|
||||
missingIds: number[]
|
||||
}> {
|
||||
const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
|
||||
if (ids.length === 0) return { deletedIds: [], missingIds: [] }
|
||||
const result = await query<{ id: number }>(
|
||||
`DELETE FROM internal_sku_channels WHERE id = ANY($1::bigint[]) RETURNING id`,
|
||||
[ids],
|
||||
)
|
||||
const deletedIds = result.rows.map((row) => Number(row.id))
|
||||
const deleted = new Set(deletedIds)
|
||||
return {
|
||||
deletedIds,
|
||||
missingIds: ids.filter((id) => !deleted.has(id)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { containsCjk } from './internal-sku-resolver.js'
|
||||
|
||||
export type InternalSkuMappingMatchType =
|
||||
| 'sku_code'
|
||||
@@ -114,10 +115,6 @@ export async function upsertInternalSkuMapping(input: {
|
||||
return Number(result.rows[0]?.id || 0)
|
||||
}
|
||||
|
||||
function containsCjk(value: string): boolean {
|
||||
return /[\u4e00-\u9fa5]/.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单项级内部 SKU 补全:
|
||||
* - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveInternalSkuKey } from './internal-sku-resolver.js'
|
||||
|
||||
test('内部 SKU:入口显式值最优先', () => {
|
||||
assert.equal(
|
||||
resolveInternalSkuKey({
|
||||
snapshot: { internalSku: '映射后的内部SKU' },
|
||||
manualSkuKey: '平台码123',
|
||||
skuName: '商品名',
|
||||
}),
|
||||
'映射后的内部SKU',
|
||||
)
|
||||
})
|
||||
|
||||
test('内部 SKU:纯数字平台码不会压过中文商品名', () => {
|
||||
assert.equal(
|
||||
resolveInternalSkuKey({
|
||||
manualSkuKey: '5003',
|
||||
skuCode: '5003',
|
||||
skuName: '幸运币2个',
|
||||
spec: { outSkuSn: '5003' },
|
||||
}),
|
||||
'幸运币2个',
|
||||
)
|
||||
})
|
||||
|
||||
test('内部 SKU:中文 skuNick 优先于 outSkuSn 和平台码', () => {
|
||||
assert.equal(
|
||||
resolveInternalSkuKey({
|
||||
skuCode: 'english-platform-code',
|
||||
skuName: '展示标题',
|
||||
spec: { skuNick: '内部中文SKU', outSkuSn: '规格码' },
|
||||
}),
|
||||
'内部中文SKU',
|
||||
)
|
||||
})
|
||||
|
||||
test('内部 SKU:没有中文时按历史生成列顺序回退', () => {
|
||||
assert.equal(
|
||||
resolveInternalSkuKey({
|
||||
manualSkuKey: 'manual-key',
|
||||
skuCode: 'sku-code',
|
||||
skuName: 'sku-name',
|
||||
spec: { skuNick: 'nick', outSkuSn: 'out' },
|
||||
}),
|
||||
'manual-key',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FulfillmentChannel } from './channel-types.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
export type InternalSkuResolverInput = {
|
||||
manualSkuKey?: unknown
|
||||
skuCode?: unknown
|
||||
skuName?: unknown
|
||||
spec?: unknown
|
||||
snapshot?: unknown
|
||||
}
|
||||
|
||||
function asObject(value: unknown): JsonObject {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
|
||||
}
|
||||
|
||||
export function normalizeInternalSku(value: unknown): string {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
export function containsCjk(value: string): boolean {
|
||||
return /[\u4e00-\u9fa5]/.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部 SKU 的唯一解析口径。
|
||||
*
|
||||
* 1. 入口显式写入的 snapshot.internalSku 最权威;
|
||||
* 2. 旧数据优先使用中文键,避免纯数字平台码压过中文 skuNick/sku_name;
|
||||
* 3. 没有中文时按生成列的历史顺序回退。
|
||||
*/
|
||||
export function resolveInternalSkuKey(input: InternalSkuResolverInput): string {
|
||||
const snapshot = asObject(input.snapshot)
|
||||
const spec = asObject(input.spec)
|
||||
const explicit = normalizeInternalSku(snapshot.internalSku)
|
||||
if (explicit) return explicit
|
||||
|
||||
const manual = normalizeInternalSku(input.manualSkuKey)
|
||||
const skuNick = normalizeInternalSku(spec.skuNick)
|
||||
const outSkuSn = normalizeInternalSku(spec.outSkuSn)
|
||||
const skuCode = normalizeInternalSku(input.skuCode)
|
||||
const skuName = normalizeInternalSku(input.skuName)
|
||||
|
||||
return (
|
||||
[manual, skuNick, outSkuSn, skuName].find((value) => value && containsCjk(value)) ||
|
||||
[manual, skuNick, outSkuSn, skuCode, skuName].find(Boolean) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
/** 渠道选择里使用的稳定顺序,仅用于同优先级时生成诊断,不用于静默改渠道。 */
|
||||
export const INTERNAL_SKU_CHANNEL_ORDER: readonly FulfillmentChannel[] = [
|
||||
'affiliate_dash',
|
||||
'work_order',
|
||||
'third_party_voucher',
|
||||
'manual',
|
||||
]
|
||||
|
||||
export function compareFulfillmentChannel(a: FulfillmentChannel, b: FulfillmentChannel): number {
|
||||
return INTERNAL_SKU_CHANNEL_ORDER.indexOf(a) - INTERNAL_SKU_CHANNEL_ORDER.indexOf(b)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js'
|
||||
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
|
||||
import { normalizeProductMatchName } from '../order/product-match-utils.js'
|
||||
import { resolveInternalSkuForOrderItem } from './internal-sku-mapping-service.js'
|
||||
import { resolveInternalSkuKey } from './internal-sku-resolver.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
|
||||
export type FulfillmentItem = {
|
||||
@@ -129,9 +130,19 @@ export async function resolveOrderItemForFulfillment({
|
||||
skuCode: externalSkuCode,
|
||||
skuName: externalSkuName,
|
||||
})
|
||||
const nextSpec = internalSkuMapping
|
||||
const mappedSpec = internalSkuMapping
|
||||
? { ...(isPlainObject(item.spec) ? item.spec : {}), skuNick: internalSkuMapping.internalSku }
|
||||
: item.spec
|
||||
const resolvedInternalSku = resolveInternalSkuKey({
|
||||
spec: mappedSpec,
|
||||
snapshot: item.snapshot,
|
||||
skuCode: externalSkuCode,
|
||||
skuName: externalSkuName,
|
||||
})
|
||||
// 统一把内部 SKU 固化到 spec.skuNick,使 manual_sku_key 生成列和所有后续管道使用同一键。
|
||||
const nextSpec = resolvedInternalSku
|
||||
? { ...(isPlainObject(mappedSpec) ? mappedSpec : {}), skuNick: resolvedInternalSku }
|
||||
: mappedSpec
|
||||
|
||||
const snapshot = {
|
||||
...(isPlainObject(item.snapshot) ? item.snapshot : {}),
|
||||
@@ -154,9 +165,9 @@ export async function resolveOrderItemForFulfillment({
|
||||
}
|
||||
: null,
|
||||
isConfigured: candidate.isConfigured,
|
||||
...(resolvedInternalSku ? { internalSku: resolvedInternalSku } : {}),
|
||||
...(internalSkuMapping
|
||||
? {
|
||||
internalSku: internalSkuMapping.internalSku,
|
||||
internalSkuSource: internalSkuMapping.source,
|
||||
internalSkuMappingId: internalSkuMapping.mappingId,
|
||||
}
|
||||
|
||||
@@ -117,14 +117,14 @@ test('resolveOpen91QueryState returns success only when every task is ready', ()
|
||||
test('buildOpen91CardItem mirrors claim link into jumpLink and leaves password empty', () => {
|
||||
assert.deepEqual(
|
||||
buildOpen91CardItem({
|
||||
claimUrl: 'https://example.com/#/claim/abc123xyz',
|
||||
claimUrl: 'https://example.com/h5/6919156612984680391',
|
||||
expireTime: '2026-05-05 12:00:00',
|
||||
}),
|
||||
{
|
||||
cardNo: 'https://example.com/#/claim/abc123xyz',
|
||||
cardNo: 'https://example.com/h5/6919156612984680391',
|
||||
cardPwd: '',
|
||||
expireTime: '2026-05-05 12:00:00',
|
||||
jumpLink: 'https://example.com/#/claim/abc123xyz',
|
||||
jumpLink: 'https://example.com/h5/6919156612984680391',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
|
||||
import {
|
||||
buildDouyinMashidaBuyerClaimUrl,
|
||||
buildDouyinMashidaClaimMessage,
|
||||
isDouyinMashidaH5ClaimBase,
|
||||
renderDouyinMashidaMessageTemplate,
|
||||
resolveDouyinMashidaClaimUrl,
|
||||
} from './claim-link.js'
|
||||
import {
|
||||
createDefaultDouyinMashidaSourceConfig,
|
||||
@@ -45,22 +44,6 @@ test('抖音配置归一化:claimLinkBase 去尾斜杠,开关可关,模板
|
||||
assert.equal(config.claimMessageEnabled, false)
|
||||
})
|
||||
|
||||
test('买家领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退全局领取页', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaClaimUrl(
|
||||
'AbC123',
|
||||
buildConfig({ claimLinkBase: 'https://x.ngrok-free.app/claim/' }),
|
||||
),
|
||||
'https://x.ngrok-free.app/claim/AbC123',
|
||||
)
|
||||
|
||||
assert.equal(resolveDouyinMashidaClaimUrl('AbC123', buildConfig()), '')
|
||||
assert.equal(
|
||||
resolveDouyinMashidaClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
test('模板占位符替换:支持 link/sku/orderNo/productTitle,空值保留占位符', () => {
|
||||
assert.equal(
|
||||
renderDouyinMashidaMessageTemplate(
|
||||
@@ -105,16 +88,6 @@ test('发消息正文:无领取链接则不发(返回空串),默认模
|
||||
)
|
||||
})
|
||||
|
||||
test('领取链接基址形态:/h5 与 /h5/xxx 判为 H5 订单号形态,其它不是', () => {
|
||||
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5'), true)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5/'), true)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5/anything'), true)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase(' https://ks.khhao.com/claim '), false)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5x'), false)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase(''), false)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase(undefined), false)
|
||||
})
|
||||
|
||||
test('买家领取消息链接:没有自动发货领取单元(人工单)一律返回空串 = 不发消息', () => {
|
||||
// 线上实测的缺陷场景:未命中映射的「套装-牛仔情诗」单回落人工,却收到了「已发货」领取链接
|
||||
assert.equal(
|
||||
@@ -126,19 +99,18 @@ test('买家领取消息链接:没有自动发货领取单元(人工单)
|
||||
'',
|
||||
)
|
||||
|
||||
// token 形态基址同样不发:人工单本来就没有 token,不能拿订单号去凑
|
||||
// 历史 /claim 基址同样不发:人工单本来就没有自动发货单元
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: false,
|
||||
tokenClaimUrl: '',
|
||||
}),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
test('买家领取消息链接:有自动发货领取单元时按基址形态拼接', () => {
|
||||
test('买家领取消息链接:有自动发货领取单元时统一生成 /h5/订单号', () => {
|
||||
// /h5 形态:拼订单号(结尾斜杠、结尾多斜杠都容忍)
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
@@ -157,15 +129,14 @@ test('买家领取消息链接:有自动发货领取单元时按基址形态
|
||||
'https://ks.khhao.com/h5/6918490107300556291',
|
||||
)
|
||||
|
||||
// token 形态:只用 resolveOrderItemClaimLink 解析出的链接
|
||||
// 历史 /claim 基址也归一化到 /h5,不再回退 token 链接
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: true,
|
||||
tokenClaimUrl: 'https://ks.khhao.com/claim/AbC123',
|
||||
}),
|
||||
'https://ks.khhao.com/claim/AbC123',
|
||||
'https://ks.khhao.com/h5/6918490107300556291',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -176,7 +147,7 @@ test('买家领取消息链接:基址或订单号缺失时返回空串(不
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: true,
|
||||
}),
|
||||
'',
|
||||
buildOrderClaimUrl('6918490107300556291'),
|
||||
)
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildConfiguredClaimUrl } from '../../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
|
||||
import {
|
||||
DEFAULT_CLAIM_MESSAGE_TEMPLATE,
|
||||
getDouyinMashidaSourceConfig,
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
/**
|
||||
* 抖音码速达的买家领取链接与发货消息拼装(纯函数,便于单测)。
|
||||
*
|
||||
* 领取链接 = `config.claimLinkBase`(去尾斜杠) + `/` + token。
|
||||
* ⚠️ 这里**只认** `claimLinkBase`,绝不回退 `runtimeConfig.orders.claimBaseUrl`
|
||||
* (那条口径的默认基址属于主站默认领取页,两者不能互相回退)。
|
||||
* 买家链接统一为 `/h5/<平台订单号>`;配置了更具体的领取站基址时沿用其站点。
|
||||
*/
|
||||
|
||||
export type DouyinMashidaClaimMessageVars = {
|
||||
@@ -20,25 +18,6 @@ export type DouyinMashidaClaimMessageVars = {
|
||||
productTitle?: string | undefined
|
||||
}
|
||||
|
||||
/** 买家领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */
|
||||
export function resolveDouyinMashidaClaimUrl(
|
||||
token: unknown,
|
||||
config: DouyinMashidaSourceConfig = getDouyinMashidaSourceConfig(),
|
||||
): string {
|
||||
return buildConfiguredClaimUrl(config.claimLinkBase, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取链接基址是否是**路径式 H5 订单号**形态(`https://<域名>/h5`、`.../h5/xxx`)。
|
||||
*
|
||||
* 这种基址拼的是**订单号**(`/h5/<订单号>`),不是 claim token;两种形态的拼接规则不同,
|
||||
* 判断只此一处,避免各调用方各写一份。
|
||||
*/
|
||||
export function isDouyinMashidaH5ClaimBase(claimLinkBase: unknown): boolean {
|
||||
const base = normalizeClaimLinkBase(claimLinkBase)
|
||||
return Boolean(base) && (base.endsWith('/h5') || base.includes('/h5/'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 买家消息里真正要发出去的领取链接(**空串 = 这条消息不许发**)。
|
||||
*
|
||||
@@ -52,40 +31,21 @@ export function isDouyinMashidaH5ClaimBase(claimLinkBase: unknown): boolean {
|
||||
* 历史缺陷:`/h5` 基址原先走了一条捷径——只拼订单号、跳过履约任务校验,
|
||||
* 于是人工单也照样收到领取消息(线上实测:未命中映射的「套装-牛仔情诗」单被发了链接)。
|
||||
*
|
||||
* 拼接规则按基址形态分流:
|
||||
* - `/h5` 形态 → `claimLinkBase` + `/` + 订单号;
|
||||
* - 其它形态 → `resolveOrderItemClaimLink` 解析出的 token 链接。
|
||||
* 拼接规则:有自动发货领取单元时,所有来源统一发 `/h5/<平台订单号>`。
|
||||
* 历史配置的 `/#/claim`、`/claim` 等基址也会由公共 helper 归一化到 `/h5`。
|
||||
*/
|
||||
export function buildDouyinMashidaBuyerClaimUrl(input: {
|
||||
claimLinkBase: unknown
|
||||
/** 平台订单号(`/h5` 形态用) */
|
||||
/** 平台订单号 */
|
||||
orderNo: unknown
|
||||
/** 该订单项是否有自动发货领取单元(`resolveOrderItemClaimLink().taskId` 非空即为有) */
|
||||
hasAutoClaimUnit: boolean
|
||||
/** token 形态链接(`resolveOrderItemClaimLink().claimUrl`) */
|
||||
tokenClaimUrl?: unknown
|
||||
}): string {
|
||||
if (!input.hasAutoClaimUnit) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const base = normalizeClaimLinkBase(input.claimLinkBase)
|
||||
if (!base) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (isDouyinMashidaH5ClaimBase(base)) {
|
||||
return buildConfiguredClaimUrl(base, String(input.orderNo ?? '').trim())
|
||||
}
|
||||
|
||||
return String(input.tokenClaimUrl ?? '').trim()
|
||||
}
|
||||
|
||||
/** 基址归一化:去首尾空白与结尾斜杠。 */
|
||||
function normalizeClaimLinkBase(claimLinkBase: unknown): string {
|
||||
return String(claimLinkBase ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
return buildOrderClaimUrl(input.orderNo, input.claimLinkBase || undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -254,31 +254,28 @@ async function notifyBuyerClaimLink(input: {
|
||||
const claimLinkBase = String(input.config.claimLinkBase || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
if (!claimLinkBase) {
|
||||
return
|
||||
}
|
||||
|
||||
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
||||
const receiptOrderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
||||
const claimOrderNo = String(input.order.platform_order_id || receiptOrderNo).trim()
|
||||
|
||||
// 先核对履约任务:`resolveOrderItemClaimLink` 只有在该订单项存在带 token 的领取单元时才给 taskId。
|
||||
const claimLink = await resolveOrderItemClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderNo: claimOrderNo,
|
||||
orderItemId: input.orderItem.id,
|
||||
claimLinkBase,
|
||||
})
|
||||
|
||||
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase,
|
||||
orderNo,
|
||||
orderNo: claimOrderNo,
|
||||
hasAutoClaimUnit: Boolean(claimLink.taskId),
|
||||
tokenClaimUrl: claimLink.claimUrl,
|
||||
})
|
||||
|
||||
if (!claimUrl) {
|
||||
logIntegration('[douyin-mashida/notify]', '该订单项没有自动发货领取单元,跳过领取链接消息', {
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
orderNo,
|
||||
orderNo: claimOrderNo,
|
||||
claimLinkBase,
|
||||
taskCount: claimLink.taskCount,
|
||||
hasClaimUnit: Boolean(claimLink.taskId),
|
||||
@@ -290,19 +287,24 @@ async function notifyBuyerClaimLink(input: {
|
||||
config: input.config,
|
||||
claimUrl,
|
||||
sku: input.orderItem.sku_code,
|
||||
orderNo,
|
||||
orderNo: claimOrderNo,
|
||||
productTitle: input.orderItem.sku_name,
|
||||
})
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
|
||||
await sendDouyinMashidaOrderMessage({ orderId: orderNo, content, config: input.config })
|
||||
await sendDouyinMashidaOrderMessage({
|
||||
orderId: receiptOrderNo,
|
||||
content,
|
||||
config: input.config,
|
||||
})
|
||||
|
||||
logIntegration('[douyin-mashida/notify]', '已把领取链接发给买家(order.message)', {
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
orderNo,
|
||||
orderNo: claimOrderNo,
|
||||
receiptOrderNo,
|
||||
taskId: claimLink.taskId,
|
||||
claimUrl,
|
||||
})
|
||||
|
||||
@@ -18,13 +18,11 @@ import {
|
||||
* - `uid` 是抖店商户号;`secret` 是用户秘钥(两者都由平台方分配)。
|
||||
* - `certLink` 是我们发给平台的固定兑换链接(`coupons.syncV2` 的 `cert_link`),
|
||||
* **必须做成配置项**:链接一旦同步给平台就收不回来,换域名时只影响新券。
|
||||
* 命中 affiliate-dash 映射的订单会优先用「买家领取链接」(`claimLinkBase` + token),
|
||||
* 此时 `certLink` 退化为兜底。
|
||||
* - `claimLinkBase` 是**买家领取页**基址,必须渲染 affiliate-dash 领取流程
|
||||
* (输 UID → 二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」完成发货)。
|
||||
* 本仓库前端该页面是 `pages/claim/ClaimPage`,路径式 `https://<域名>/claim` 与
|
||||
* hash 式 `https://<域名>/#/claim` 都会渲染它;它**不是** `runtimeConfig.orders.claimBaseUrl`
|
||||
* (两条口径不能互相回退)。
|
||||
* 买家领取链接统一为 `/h5/<订单号>`;未配 `claimLinkBase` 时使用全局领取站,
|
||||
* `certLink` 仅在订单号或领取站不可用时兜底。
|
||||
* - `claimLinkBase` 是**买家领取页**基址;出站链接会在其站点下统一生成
|
||||
* `/h5/<平台订单号>`,历史 `/#/claim`、`/claim` 基址也会被归一化。
|
||||
* 本仓库前端该页面是 `pages/claim/ClaimPage`,走 affiliate-dash 领取流程。
|
||||
* - `handledTopics` 是本地处理白名单:白名单外的 topic 只落库留痕、不产生副作用。
|
||||
*/
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export function resolveWorkOrderRulePricing(input: {
|
||||
skuQuantity: number
|
||||
unitPriceFen: number
|
||||
fixedRewardAmount: number
|
||||
pricingMode: 'fixed' | 'unit' | string
|
||||
sharingEnabled: boolean
|
||||
sharingAutoFromOrder: boolean
|
||||
sharingTotalQuantity: number
|
||||
@@ -43,7 +44,7 @@ export function resolveWorkOrderRulePricing(input: {
|
||||
: Math.max(0, Number(input.sharingUnitReward) || 0)
|
||||
const rewardAmount = input.sharingEnabled
|
||||
? sharingTotalQuantity * sharingUnitReward
|
||||
: unitPriceFen > 0
|
||||
: input.pricingMode === 'unit'
|
||||
? unitPriceFen * skuQuantity
|
||||
: Math.max(0, Number(input.fixedRewardAmount) || 0)
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ function buildRule(overrides: Partial<WorkProductRuleRow> = {}): WorkProductRule
|
||||
auto_create: true,
|
||||
reward_amount: 0,
|
||||
unit_price_fen: 0,
|
||||
pricing_mode: 'fixed',
|
||||
required_deposit_amount: 0,
|
||||
deposit_threshold_amount: 20000,
|
||||
sharing_enabled: false,
|
||||
@@ -141,6 +142,7 @@ const commanderRule = buildRule({
|
||||
rule_key: 'bb',
|
||||
product_name: '指挥官密钥',
|
||||
unit_price_fen: 950,
|
||||
pricing_mode: 'unit',
|
||||
sharing_enabled: true,
|
||||
sharing_auto_from_order: true,
|
||||
})
|
||||
@@ -199,6 +201,7 @@ test('抖音:命中指挥官模板后按数量计价并开拼单(9.5 元/个
|
||||
skuQuantity,
|
||||
unitPriceFen: commanderRule.unit_price_fen,
|
||||
fixedRewardAmount: commanderRule.reward_amount,
|
||||
pricingMode: commanderRule.pricing_mode,
|
||||
sharingEnabled: commanderRule.sharing_enabled === true,
|
||||
sharingAutoFromOrder: commanderRule.sharing_auto_from_order === true,
|
||||
sharingTotalQuantity: commanderRule.sharing_total_quantity,
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { isPlainObject, type JsonObject } from '../../types/json.js'
|
||||
|
||||
import { resolveOrderItemClaimLink } from '../claim/claim-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
@@ -30,10 +29,7 @@ import {
|
||||
syncDouyinMashidaCoupons,
|
||||
verifyDouyinMashidaCoupon,
|
||||
} from '../platforms/douyin-mashida/gateway-client.js'
|
||||
import {
|
||||
buildDouyinMashidaBuyerClaimUrl,
|
||||
isDouyinMashidaH5ClaimBase,
|
||||
} from '../platforms/douyin-mashida/claim-link.js'
|
||||
import { buildDouyinMashidaBuyerClaimUrl } from '../platforms/douyin-mashida/claim-link.js'
|
||||
import {
|
||||
DOUYIN_MASHIDA_PROVIDER,
|
||||
extractDouyinMashidaSubOrderId,
|
||||
@@ -295,18 +291,9 @@ async function attachDouyinMashidaCertificateToWorkOrder(input: {
|
||||
/**
|
||||
* 解析本次同步要交给平台的 `cert_link`。
|
||||
*
|
||||
* 判据:券对**所有订单**都要发(业务口径),链接按基址形态决定:
|
||||
* - `/h5` 形态基址 → `claimLinkBase/<订单号>`(买家落到自己订单的状态页,自动发货/人工处理中都合理);
|
||||
* - 其它形态基址 → 只有该订单项确实有自动发货领取单元(claim token)时才给 token 领取链接;
|
||||
* - 拿不到可用链接 → 回退固定 `config.certLink`。
|
||||
*
|
||||
* 历史缺陷:`claimLinkBase` 以 `/h5` 结尾时走了一条捷径——**无条件**下拼订单号,
|
||||
* 但那只是「拼法」问题;真正的缺陷在私信侧(无 token 也发「已发货」,见 notify-service,
|
||||
* 已用同一个纯函数修好)。这里保留 `/h5` 按订单号拼的行为,但**不再让链接依赖 token**。
|
||||
*
|
||||
* 链接解析走通用 helper(只看订单项的履约任务,不看执行器 key):
|
||||
* 抖音皮肤/礼包单已改走 affiliate_dash 通道,换执行器不会再让 cert_link 失效。
|
||||
* 同一个订单项按数量拆成多个履约单元时会有多个 token,helper 取最小 task id 并打 warn。
|
||||
* 券对**所有订单**都要发,买家链接统一使用 `/h5/<平台订单号>`;
|
||||
* 抖音配置了 `claimLinkBase` 时使用其站点,否则使用全局领取站;
|
||||
* 订单号缺失或领取站不可用时回退固定 `config.certLink`。
|
||||
*
|
||||
* ⚠️ 只对新单生效:已同步成功的券不会被改写。
|
||||
*/
|
||||
@@ -315,70 +302,24 @@ export async function resolveDouyinMashidaCertLink(input: {
|
||||
orderItem: OrderItemRow
|
||||
config: DouyinMashidaSourceConfig
|
||||
}): Promise<string> {
|
||||
const fallback = String(input.config.certLink || '').trim()
|
||||
const claimLinkBase = String(input.config.claimLinkBase || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
if (!claimLinkBase) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const claimLink = await resolveOrderItemClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
claimLinkBase: input.config.claimLinkBase,
|
||||
})
|
||||
|
||||
// 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕;
|
||||
// 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。
|
||||
if (!claimLink.taskId && claimLink.taskCount > 0) {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接',
|
||||
{
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
taskCount: claimLink.taskCount,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
|
||||
return resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: fallback,
|
||||
claimLinkBase,
|
||||
orderNo: resolveReceiptOrderId(input.order, input.orderItem),
|
||||
tokenClaimUrl: claimLink.claimUrl,
|
||||
certLink: input.config.certLink,
|
||||
claimLinkBase: input.config.claimLinkBase,
|
||||
orderNo: input.order.platform_order_id,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `cert_link` 的**纯函数**判定(便于单测)。
|
||||
*
|
||||
* 业务口径:**券对所有订单都要发**(与是否命中自动发货无关),因此链接不能退化成裸基址:
|
||||
* - `/h5` 形态基址 → 一律按**订单号**拼(买家直接落到自己订单的状态页);
|
||||
* - 其它形态基址 → 有 token 才给领取链接,否则回退固定 `certLink`。
|
||||
*/
|
||||
/** `cert_link` 的纯函数判定,便于单测。 */
|
||||
export function resolveDouyinMashidaCertLinkFromClaim(input: {
|
||||
certLink: unknown
|
||||
claimLinkBase: unknown
|
||||
orderNo: unknown
|
||||
tokenClaimUrl?: unknown
|
||||
}): string {
|
||||
const fallback = String(input.certLink ?? '').trim()
|
||||
const base = String(input.claimLinkBase ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
if (!base) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const orderNoLink = isDouyinMashidaH5ClaimBase(base)
|
||||
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: base,
|
||||
claimLinkBase: input.claimLinkBase,
|
||||
orderNo: input.orderNo,
|
||||
hasAutoClaimUnit: orderNoLink || Boolean(String(input.tokenClaimUrl ?? '').trim()),
|
||||
tokenClaimUrl: input.tokenClaimUrl,
|
||||
hasAutoClaimUnit: true,
|
||||
})
|
||||
|
||||
return claimUrl || fallback
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
generateRedeemCertificateCode,
|
||||
resolveRedeemVerifyCertNo,
|
||||
} from '../../repositories/redeem-certificate-repo.js'
|
||||
import { buildOrderClaimUrl } from '../claim/claim-service.js'
|
||||
import { createDefaultDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js'
|
||||
import {
|
||||
buildAfterVerifyMessage,
|
||||
@@ -129,65 +130,60 @@ test('平台侧是否已核销:status=3 或 verify_num>0', () => {
|
||||
assert.equal(isPlatformCouponVerified(null), false)
|
||||
})
|
||||
|
||||
// ---- 抖音 cert_link:券对所有订单都发,链接按基址形态决定 ----
|
||||
// ---- 抖音 cert_link:券对所有订单都发,链接统一使用 /h5/订单号 ----
|
||||
|
||||
test('抖音 cert_link:/h5 形态基址 → 一律按订单号拼(与是否命中自动发货无关)', () => {
|
||||
test('抖音 cert_link:/h5 基址 → 一律按订单号拼(与是否命中自动发货无关)', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/h5',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: '',
|
||||
}),
|
||||
'https://ks.khhao.com/h5/6930318258716179555',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:/h5 基址 + 有 token → 仍按订单号拼(H5 页认订单号)', () => {
|
||||
test('抖音 cert_link:/h5 基址带尾斜杠也只按订单号拼', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/h5/',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: 'https://ks.khhao.com/h5/token-abc',
|
||||
}),
|
||||
'https://ks.khhao.com/h5/6930318258716179555',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:token 形态基址 + 有 token → 用 token 领取链接', () => {
|
||||
test('抖音 cert_link:历史 /claim 基址也归一化为 /h5/订单号', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc',
|
||||
}),
|
||||
'https://ks.khhao.com/claim/token-abc',
|
||||
'https://ks.khhao.com/h5/6930318258716179555',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:token 形态基址 + 无 token(人工单 / 工单单)→ 回退固定兑换链接', () => {
|
||||
test('抖音 cert_link:订单号缺失时回退固定兑换链接', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: '',
|
||||
claimLinkBase: 'https://ks.khhao.com/h5',
|
||||
orderNo: '',
|
||||
}),
|
||||
'https://ks.khhao.com/redeem',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:未配 claimLinkBase 时回退固定链接(不报错)', () => {
|
||||
test('抖音 cert_link:未配 claimLinkBase 时回退全局 /h5 链接', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: '',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc',
|
||||
}),
|
||||
'https://ks.khhao.com/redeem',
|
||||
buildOrderClaimUrl('6930318258716179555'),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -86,9 +86,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
const config = jobConfig(job)
|
||||
const limit = clampInt(config.limit, 200, 1, 2000)
|
||||
const reconciled = await reconcilePendingCarriers({ limit: Math.min(limit, 200) })
|
||||
if (reconciled.affiliateTasksCreated > 0) {
|
||||
await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 }).catch(() => undefined)
|
||||
}
|
||||
const repairedAssigned = await reconcilePendingCarriers({
|
||||
limit: Math.min(limit, 200),
|
||||
includeAssignedMissingCarrier: true,
|
||||
})
|
||||
await assignPendingOrderItemChannels({ batchSize: 500, maxBatches: 1 }).catch(() => undefined)
|
||||
const cursor =
|
||||
config.afterId != null
|
||||
? Math.max(0, Math.trunc(Number(config.afterId)))
|
||||
@@ -101,13 +103,17 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
|
||||
const manual = await query<{ id: number; sku_key: string; config_id: number }>(
|
||||
`
|
||||
SELECT oi.id, oi.manual_sku_key AS sku_key, cfg.id AS config_id
|
||||
SELECT oi.id,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key,
|
||||
cfg.id AS config_id
|
||||
FROM order_items oi
|
||||
JOIN manual_fulfillment_configs cfg ON cfg.sku_key = oi.manual_sku_key AND cfg.enabled
|
||||
JOIN manual_fulfillment_configs cfg
|
||||
ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
|
||||
AND cfg.enabled
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
AND oi.fulfillment_config_id IS NULL
|
||||
AND oi.manual_sku_key = ANY($3::text[])
|
||||
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = ANY($3::text[])
|
||||
AND oi.id > $2
|
||||
ORDER BY oi.id
|
||||
LIMIT $1
|
||||
@@ -131,12 +137,13 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
// 人工渠道但没有配置:进「人工 SKU 未配置」队列。
|
||||
const missingManual = await query<{ id: number; sku_key: string }>(
|
||||
`
|
||||
SELECT oi.id, oi.manual_sku_key AS sku_key
|
||||
SELECT oi.id,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
AND oi.fulfillment_config_id IS NULL
|
||||
AND NOT (oi.manual_sku_key = ANY($3::text[]))
|
||||
AND NOT (resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = ANY($3::text[]))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM cost_exception_items i
|
||||
@@ -165,7 +172,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
// 已配置人工 SKU、但还没确认履约的单:进入「人工成本未记录」队列。
|
||||
const pendingManualCost = await query<{ id: number; sku_key: string }>(
|
||||
`
|
||||
SELECT oi.id, oi.manual_sku_key AS sku_key
|
||||
SELECT oi.id,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
@@ -205,14 +213,15 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
`
|
||||
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code
|
||||
FROM order_items oi
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
|
||||
AND wo.id IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id
|
||||
AND ft.executor_key IN ('affiliate_dash', 'third_party_voucher')
|
||||
AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled')
|
||||
)
|
||||
AND oi.id > $2
|
||||
ORDER BY oi.id
|
||||
@@ -245,6 +254,41 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
detail: { channel: row.channel, skuCode: row.sku_code },
|
||||
})
|
||||
}
|
||||
|
||||
// 已分配订单出现两个真实执行方时不能继续算正常渠道,必须进冲突队列人工处置。
|
||||
const conflictingCarriers = await query<{ id: number; channel: string; sku_code: string }>(
|
||||
`
|
||||
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
)::int + (
|
||||
EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled')
|
||||
)
|
||||
)::int + (
|
||||
EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled')
|
||||
)
|
||||
)::int > 1
|
||||
ORDER BY oi.id
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit],
|
||||
)
|
||||
for (const row of conflictingCarriers.rows) {
|
||||
exceptionEntries.push({
|
||||
reason: 'routing_conflict',
|
||||
scopeKey: `routing_conflict|${row.channel}|${row.sku_code}|carrier`,
|
||||
orderItemId: Number(row.id),
|
||||
detail: { channel: row.channel, skuCode: row.sku_code, issue: 'multiple_carriers' },
|
||||
})
|
||||
}
|
||||
if (exceptionEntries.length > 0) {
|
||||
await upsertAggregatedExceptionsBatch(exceptionEntries)
|
||||
}
|
||||
@@ -254,6 +298,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
...missingManual.rows,
|
||||
...pendingManualCost.rows,
|
||||
...missingCarrier.rows,
|
||||
...conflictingCarriers.rows,
|
||||
]) {
|
||||
maxId = Math.max(maxId, Number(row.id))
|
||||
}
|
||||
@@ -269,13 +314,17 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
status: 'ok',
|
||||
message:
|
||||
`人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` +
|
||||
`缺履约载体 ${missingCarrier.rows.length},异常收口 ${resolved},` +
|
||||
`自动转人工 ${fallback.switched}`,
|
||||
`缺履约载体 ${missingCarrier.rows.length},多载体冲突 ${conflictingCarriers.rows.length},异常收口 ${resolved},` +
|
||||
`自动转人工 ${fallback.switched},链路补载体 ${reconciled.workOrdersCreated + reconciled.affiliateTasksCreated + repairedAssigned.workOrdersCreated + repairedAssigned.affiliateTasksCreated},` +
|
||||
`兜底 ${reconciled.fallbacksSelected + repairedAssigned.fallbacksSelected}`,
|
||||
attached,
|
||||
missingManual: missingManual.rows.length,
|
||||
missingCarrier: missingCarrier.rows.length,
|
||||
conflictingCarriers: conflictingCarriers.rows.length,
|
||||
resolved,
|
||||
affiliateFallback: fallback.switched,
|
||||
carrierReconciliation: reconciled,
|
||||
assignedCarrierRepair: repairedAssigned,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +338,7 @@ async function resolveCarrierDispatchExceptions(): Promise<number> {
|
||||
UPDATE order_items oi
|
||||
SET fulfillment_config_id = cfg.id, updated_at = NOW()
|
||||
FROM manual_fulfillment_configs cfg
|
||||
WHERE cfg.sku_key = oi.manual_sku_key
|
||||
WHERE cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
|
||||
AND cfg.enabled
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
@@ -315,12 +364,14 @@ async function resolveCarrierDispatchExceptions(): Promise<number> {
|
||||
WHERE ce.reason = 'carrier_missing'
|
||||
AND ce.status <> 'resolved'
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||||
OR EXISTS (
|
||||
(oi.fulfillment_channel = 'work_order'
|
||||
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))
|
||||
OR (oi.fulfillment_channel = 'affiliate_dash' AND EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id
|
||||
AND ft.executor_key IN ('affiliate_dash', 'third_party_voucher')
|
||||
)
|
||||
AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled')
|
||||
))
|
||||
)
|
||||
LIMIT 500
|
||||
`,
|
||||
@@ -442,8 +493,9 @@ export async function runLedgerPostJob(job: JsonObject = {}) {
|
||||
|
||||
/** 顺序执行全部管道,供管理端「一键重算」使用。 */
|
||||
export async function runFinancePipelineOnce(input: { days?: number } = {}) {
|
||||
const channel = await runChannelAssignJob({ config: { batchSize: 1000, maxBatches: 50 } })
|
||||
// 先保证载体,再让财务镜像真实渠道;避免规则先把订单写成 assigned 却永远没有派单。
|
||||
const carrier = await runCarrierDispatchJob({ config: { limit: 500 } })
|
||||
const channel = await runChannelAssignJob({ config: { batchSize: 1000, maxBatches: 50 } })
|
||||
const product = await runProductNormalizeJob({ config: { batchSize: 1000, maxBatches: 50 } })
|
||||
const cost = await runCostSnapshotJob({ config: { batchSize: 1000, maxBatches: 50 } })
|
||||
const facts = await runSalesFactsJob({ config: { days: input.days ?? 3 } })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 内部短链服务。
|
||||
*
|
||||
* 注意(2026-07 起):
|
||||
* - 领取主路径已统一为本站 `/#/claim/{token}`,feifei 不再为 claim 生成 shortLink。
|
||||
* - 领取链接已统一为本站 `/h5/{订单号}`,feifei 不再为 claim 生成 shortLink。
|
||||
* - 本模块仅保留:历史 `/s/{code}` 解析、其它潜在内部用途。
|
||||
* - 新业务不要再依赖 shortLink 作为对外交付链接。
|
||||
*/
|
||||
@@ -46,7 +46,7 @@ export async function ensureShortLinkForTarget(
|
||||
const source = String(options.source || '').trim()
|
||||
if (source === SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM) {
|
||||
console.warn(
|
||||
'[short-link] feifei claim 已不再使用 shortLink 作为对外交付;请使用本站 /#/claim/{token} 并在 H5 拼 uid',
|
||||
'[short-link] feifei claim 已不再使用 shortLink 作为对外交付;请使用本站 /h5/{订单号} 领取页',
|
||||
)
|
||||
}
|
||||
const targetUrlHash = hashTargetUrl(normalizedTargetUrl)
|
||||
|
||||
@@ -108,6 +108,7 @@ function buildWorkOrderPlan(
|
||||
skuQuantity,
|
||||
unitPriceFen,
|
||||
fixedRewardAmount: Number(rule.reward_amount || 0),
|
||||
pricingMode: rule.pricing_mode,
|
||||
sharingEnabled: rule.sharing_enabled === true,
|
||||
sharingAutoFromOrder: rule.sharing_auto_from_order === true,
|
||||
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||
@@ -120,7 +121,7 @@ function buildWorkOrderPlan(
|
||||
return {
|
||||
skuQuantity,
|
||||
unitPriceFen,
|
||||
pricingMode: unitPriceFen > 0 ? 'unit' : 'fixed',
|
||||
pricingMode: rule.pricing_mode === 'unit' ? 'unit' : 'fixed',
|
||||
rewardAmount: pricing.rewardAmount,
|
||||
sharingEnabled,
|
||||
sharingTotalQuantity: sharingEnabled ? pricing.sharingTotalQuantity : 1,
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
upsertWorkProductRule,
|
||||
} from '../../repositories/worker-platform/work-product-rule-repo.js'
|
||||
import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js'
|
||||
import { syncAdminWorkOrderTemplateCostVersions } from '../cost-center/work-order-product-service.js'
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import {
|
||||
archiveDeletedWorkOrderTemplateProduct,
|
||||
assertWorkOrderTemplateCanEnable,
|
||||
syncWorkOrderTemplateProduct,
|
||||
} from '../cost-center/work-order-template-product-service.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
@@ -379,35 +384,76 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
|
||||
const unitPriceFen =
|
||||
payload.unitPrice === undefined && payload.unitPriceYuan === undefined
|
||||
? 0
|
||||
: normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0)
|
||||
|
||||
// 全系统只有【商品与成本中心】是标准价格,接单模板价格禁止在其他地方修改
|
||||
if (existingRule && !payload.internalAllowPriceChange) {
|
||||
const existingRewardAmount = Number(existingRule.reward_amount || 0)
|
||||
const existingUnitPrice = Number(existingRule.unit_price_fen || 0)
|
||||
const hasPricePayload =
|
||||
payload.rewardAmount !== undefined ||
|
||||
payload.rewardAmountYuan !== undefined ||
|
||||
payload.unitPrice !== undefined ||
|
||||
payload.unitPriceYuan !== undefined
|
||||
if (
|
||||
hasPricePayload &&
|
||||
(rewardAmount !== existingRewardAmount || unitPriceFen !== existingUnitPrice)
|
||||
) {
|
||||
throw createHttpError(
|
||||
'接单模板价格已由【商品与成本中心】统一定价,价格更新只能走商品与成本中心。请前往成本中心修改标准价格',
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'price_update_restricted_to_cost_center',
|
||||
},
|
||||
)
|
||||
}
|
||||
const hasRewardPayload =
|
||||
payload.rewardAmount !== undefined || payload.rewardAmountYuan !== undefined
|
||||
const hasUnitPricePayload = payload.unitPrice !== undefined || payload.unitPriceYuan !== undefined
|
||||
const incomingRewardAmount = normalizeAmountFen(
|
||||
payload.rewardAmount ?? payload.rewardAmountYuan,
|
||||
0,
|
||||
)
|
||||
const incomingUnitPriceFen = normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0)
|
||||
const existingRewardAmount = Number(existingRule?.reward_amount || 0)
|
||||
const existingUnitPriceFen = Number(existingRule?.unit_price_fen || 0)
|
||||
const rawPricingMode = String(payload.pricingMode ?? payload.pricing_mode ?? '').trim()
|
||||
if (rawPricingMode && rawPricingMode !== 'fixed' && rawPricingMode !== 'unit') {
|
||||
throw createHttpError('接单模板计价方式无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_rule_pricing_mode_invalid',
|
||||
})
|
||||
}
|
||||
const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false)
|
||||
const existingPricingMode = existingRule?.pricing_mode === 'unit' ? 'unit' : 'fixed'
|
||||
const pricingMode: 'fixed' | 'unit' =
|
||||
rawPricingMode === 'unit' ? 'unit' : rawPricingMode === 'fixed' ? 'fixed' : existingPricingMode
|
||||
let carriedPricingAmount =
|
||||
existingPricingMode === 'unit' ? existingUnitPriceFen : existingRewardAmount
|
||||
if (existingRule && pricingMode !== existingPricingMode) {
|
||||
const activeCostResult = await query<{ amount_fen: number }>(
|
||||
`SELECT amount_fen
|
||||
FROM cost_versions
|
||||
WHERE work_product_rule_id = $1
|
||||
AND scope_type = 'rule'
|
||||
AND status = 'active'
|
||||
AND effective_from <= NOW()
|
||||
AND (effective_to IS NULL OR effective_to > NOW())
|
||||
ORDER BY effective_from DESC, id DESC
|
||||
LIMIT 1`,
|
||||
[existingRule.id],
|
||||
)
|
||||
const activeCostAmount = Number(activeCostResult.rows[0]?.amount_fen || 0)
|
||||
if (activeCostAmount > 0) carriedPricingAmount = activeCostAmount
|
||||
}
|
||||
|
||||
// 模板保存只维护业务配置。价格只能由商品与成本中心的统一命令写入。
|
||||
if (
|
||||
(hasRewardPayload && incomingRewardAmount !== existingRewardAmount) ||
|
||||
(hasUnitPricePayload && incomingUnitPriceFen !== existingUnitPriceFen) ||
|
||||
(!existingRule && (incomingRewardAmount > 0 || incomingUnitPriceFen > 0))
|
||||
) {
|
||||
throw createHttpError(
|
||||
'接单模板价格只能由【商品与成本中心】维护。请先保存模板,再到成本中心配置价格',
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'price_update_restricted_to_cost_center',
|
||||
},
|
||||
)
|
||||
}
|
||||
const rewardAmount =
|
||||
pricingMode !== existingPricingMode || !existingRule
|
||||
? pricingMode === 'fixed'
|
||||
? carriedPricingAmount
|
||||
: 0
|
||||
: existingRewardAmount
|
||||
const unitPriceFen =
|
||||
pricingMode !== existingPricingMode || !existingRule
|
||||
? pricingMode === 'unit'
|
||||
? carriedPricingAmount
|
||||
: 0
|
||||
: existingUnitPriceFen
|
||||
const sharingEnabled = hasOwnPayload(payload, 'sharingEnabled')
|
||||
? normalizeBoolean(payload.sharingEnabled, false)
|
||||
: hasOwnPayload(payload, 'sharing_enabled')
|
||||
? normalizeBoolean(payload.sharing_enabled, false)
|
||||
: existingRule?.sharing_enabled === true
|
||||
const acceptanceMode = normalizeWorkOrderAcceptanceMode(
|
||||
hasOwnPayload(payload, 'acceptanceMode') || hasOwnPayload(payload, 'acceptance_mode')
|
||||
? payload.acceptanceMode || payload.acceptance_mode
|
||||
@@ -423,11 +469,14 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) ||
|
||||
hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) ||
|
||||
hasConfiguredSharingValue(payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan)
|
||||
const requestedSharingAutoFromOrder = hasOwnPayload(payload, 'sharingAutoFromOrder')
|
||||
? normalizeBoolean(payload.sharingAutoFromOrder, false)
|
||||
: hasOwnPayload(payload, 'sharing_auto_from_order')
|
||||
? normalizeBoolean(payload.sharing_auto_from_order, false)
|
||||
: (existingRule?.sharing_auto_from_order === true && !hasManualSharingConfig) ||
|
||||
(!existingRule && sharingEnabled && pricingMode === 'unit' && !hasManualSharingConfig)
|
||||
const sharingAutoFromOrder =
|
||||
sharingEnabled &&
|
||||
unitPriceFen > 0 &&
|
||||
(normalizeBoolean(payload.sharingAutoFromOrder ?? payload.sharing_auto_from_order, false) ||
|
||||
!hasManualSharingConfig)
|
||||
pricingMode === 'unit' && requestedSharingAutoFromOrder && !hasManualSharingConfig
|
||||
const hasManualSharingQuantity = hasConfiguredSharingValue(
|
||||
payload.sharingTotalQuantity ?? payload.sharing_total_quantity,
|
||||
)
|
||||
@@ -436,7 +485,7 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
)
|
||||
if (
|
||||
sharingEnabled &&
|
||||
unitPriceFen > 0 &&
|
||||
pricingMode === 'unit' &&
|
||||
!sharingAutoFromOrder &&
|
||||
hasManualSharingQuantity !== hasManualSharingUnitReward
|
||||
) {
|
||||
@@ -445,17 +494,19 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
errorCode: 'work_product_rule_sharing_override_incomplete',
|
||||
})
|
||||
}
|
||||
const sharingUnitReward = normalizeAmountFen(
|
||||
payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
|
||||
0,
|
||||
)
|
||||
const sharingUnitReward =
|
||||
payload.sharingUnitReward !== undefined || payload.sharingUnitRewardYuan !== undefined
|
||||
? normalizeAmountFen(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, 0)
|
||||
: Number(existingRule?.sharing_unit_reward || 0)
|
||||
const sharingTotalAmount = normalizeAmountFen(
|
||||
payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan,
|
||||
0,
|
||||
)
|
||||
const sharingTotalQuantity = resolveSharingQuantity(
|
||||
sharingTotalAmount,
|
||||
normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1),
|
||||
payload.sharingTotalQuantity !== undefined || payload.sharing_total_quantity !== undefined
|
||||
? normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1)
|
||||
: normalizePositiveInteger(existingRule?.sharing_total_quantity, 1),
|
||||
sharingUnitReward,
|
||||
)
|
||||
const resolvedSharingTotalAmount =
|
||||
@@ -473,77 +524,90 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
? 0
|
||||
: resolvedSharingTotalAmount
|
||||
: rewardAmount
|
||||
const requestedEnabled = hasOwnPayload(payload, 'enabled')
|
||||
? normalizeBoolean(payload.enabled, false)
|
||||
: existingRule?.enabled === true
|
||||
const enabled = existingRule ? requestedEnabled : false
|
||||
if (existingRule && enabled) {
|
||||
await assertWorkOrderTemplateCanEnable(existingRule.id)
|
||||
}
|
||||
const fields = normalizeRequirementFieldsFromPayload(payload)
|
||||
const ruleKey = existingRule?.rule_key || randomId('rule-')
|
||||
const rule = await upsertWorkProductRule({
|
||||
ruleKey,
|
||||
provider: hasOwnPayload(payload, 'provider')
|
||||
? String(payload.provider || '').trim()
|
||||
: String(existingRule?.provider || '').trim(),
|
||||
platform: hasOwnPayload(payload, 'platform')
|
||||
? String(payload.platform || '').trim()
|
||||
: String(existingRule?.platform || '').trim(),
|
||||
shopId:
|
||||
hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id')
|
||||
? String(payload.shopId || payload.shop_id || '').trim()
|
||||
: String(existingRule?.shop_id || '').trim(),
|
||||
skuCode:
|
||||
hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code')
|
||||
? skuCode
|
||||
: String(existingRule?.sku_code || '').trim(),
|
||||
productName: productName || matchedProductName,
|
||||
matchType:
|
||||
hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type')
|
||||
? normalizeMatchType(payload.matchType || payload.match_type)
|
||||
: normalizeMatchType(existingRule?.match_type),
|
||||
categoryId:
|
||||
normalizeOptionalId(payload.categoryId || payload.category_id) ||
|
||||
defaults.category?.id ||
|
||||
null,
|
||||
enabled: normalizeBoolean(payload.enabled, true),
|
||||
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true),
|
||||
rewardAmount: finalRewardAmount,
|
||||
unitPriceFen,
|
||||
// 商品规则不配置押金;工单创建后由运营按需手工填写。
|
||||
requiredDepositAmount: 0,
|
||||
depositThresholdAmount: 0,
|
||||
sharingEnabled,
|
||||
sharingTotalQuantity,
|
||||
sharingUnitReward,
|
||||
sharingAutoFromOrder,
|
||||
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
|
||||
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
|
||||
acceptanceMode,
|
||||
giftCooldownHours:
|
||||
hasOwnPayload(payload, 'giftCooldownHours') || hasOwnPayload(payload, 'gift_cooldown_hours')
|
||||
? normalizeWorkOrderGiftCooldownHours(
|
||||
payload.giftCooldownHours ?? payload.gift_cooldown_hours,
|
||||
)
|
||||
: normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours),
|
||||
giftCooldownMinutes:
|
||||
hasOwnPayload(payload, 'giftCooldownMinutes') ||
|
||||
hasOwnPayload(payload, 'gift_cooldown_minutes')
|
||||
? normalizeWorkOrderGiftCooldownMinutes(
|
||||
payload.giftCooldownMinutes ?? payload.gift_cooldown_minutes,
|
||||
)
|
||||
: normalizeWorkOrderGiftCooldownMinutes(existingRule?.gift_cooldown_minutes),
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
matchJson: JSON.stringify(match),
|
||||
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||
now: nowIso(),
|
||||
const hasCategoryPayload =
|
||||
hasOwnPayload(payload, 'categoryId') || hasOwnPayload(payload, 'category_id')
|
||||
const categoryId = hasCategoryPayload
|
||||
? normalizeOptionalId(payload.categoryId ?? payload.category_id)
|
||||
: existingRule?.category_id || defaults.category?.id || null
|
||||
const projection = await withTransaction(async (client) => {
|
||||
const savedRule = await upsertWorkProductRule({
|
||||
ruleKey,
|
||||
provider: hasOwnPayload(payload, 'provider')
|
||||
? String(payload.provider || '').trim()
|
||||
: String(existingRule?.provider || '').trim(),
|
||||
platform: hasOwnPayload(payload, 'platform')
|
||||
? String(payload.platform || '').trim()
|
||||
: String(existingRule?.platform || '').trim(),
|
||||
shopId:
|
||||
hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id')
|
||||
? String(payload.shopId || payload.shop_id || '').trim()
|
||||
: String(existingRule?.shop_id || '').trim(),
|
||||
skuCode:
|
||||
hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code')
|
||||
? skuCode
|
||||
: String(existingRule?.sku_code || '').trim(),
|
||||
productName: productName || matchedProductName,
|
||||
matchType:
|
||||
hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type')
|
||||
? normalizeMatchType(payload.matchType || payload.match_type)
|
||||
: normalizeMatchType(existingRule?.match_type),
|
||||
categoryId,
|
||||
enabled,
|
||||
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true),
|
||||
rewardAmount: finalRewardAmount,
|
||||
unitPriceFen,
|
||||
pricingMode,
|
||||
// 商品规则不配置押金;工单创建后由运营按需手工填写。
|
||||
requiredDepositAmount: 0,
|
||||
depositThresholdAmount: 0,
|
||||
sharingEnabled,
|
||||
sharingTotalQuantity,
|
||||
sharingUnitReward,
|
||||
sharingAutoFromOrder,
|
||||
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
|
||||
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
|
||||
acceptanceMode,
|
||||
giftCooldownHours:
|
||||
hasOwnPayload(payload, 'giftCooldownHours') || hasOwnPayload(payload, 'gift_cooldown_hours')
|
||||
? normalizeWorkOrderGiftCooldownHours(
|
||||
payload.giftCooldownHours ?? payload.gift_cooldown_hours,
|
||||
)
|
||||
: normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours),
|
||||
giftCooldownMinutes:
|
||||
hasOwnPayload(payload, 'giftCooldownMinutes') ||
|
||||
hasOwnPayload(payload, 'gift_cooldown_minutes')
|
||||
? normalizeWorkOrderGiftCooldownMinutes(
|
||||
payload.giftCooldownMinutes ?? payload.gift_cooldown_minutes,
|
||||
)
|
||||
: normalizeWorkOrderGiftCooldownMinutes(existingRule?.gift_cooldown_minutes),
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
matchJson: JSON.stringify(match),
|
||||
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||
now: nowIso(),
|
||||
client,
|
||||
})
|
||||
if (!savedRule) {
|
||||
throw createHttpError('接单模板保存失败', { statusCode: 500 })
|
||||
}
|
||||
const productProjection = await syncWorkOrderTemplateProduct(savedRule.id, client)
|
||||
return { rule: savedRule, productProjection }
|
||||
})
|
||||
|
||||
// 模板价格变更即同步成本版本(事件驱动);失败不阻塞模板保存,
|
||||
// 夜间巡检与后台「同步发单金额」按钮仍可修复。
|
||||
if (rule) {
|
||||
try {
|
||||
await syncAdminWorkOrderTemplateCostVersions(rule.id)
|
||||
} catch {
|
||||
// 成本版本同步失败不允许影响模板本身;由巡检补齐。
|
||||
}
|
||||
return {
|
||||
rule: mapWorkProductRule(projection.rule),
|
||||
productId: projection.productProjection.productId,
|
||||
productStatus: projection.productProjection.status,
|
||||
costConfigurationRequired: !projection.productProjection.hasActiveCost,
|
||||
}
|
||||
|
||||
return { rule: mapWorkProductRule(rule) }
|
||||
}
|
||||
|
||||
/** 根据工单创建时的规格,计算当前模板可同步的价格。 */
|
||||
@@ -562,6 +626,7 @@ export function resolveWorkOrderTemplateSyncPrice(input: {
|
||||
skuQuantity,
|
||||
unitPriceFen: Number(input.rule.unit_price_fen || 0),
|
||||
fixedRewardAmount: Number(input.rule.reward_amount || 0),
|
||||
pricingMode: input.rule.pricing_mode,
|
||||
sharingEnabled: input.rule.sharing_enabled === true,
|
||||
sharingAutoFromOrder: input.rule.sharing_auto_from_order === true,
|
||||
sharingTotalQuantity: Number(input.rule.sharing_total_quantity || 1),
|
||||
@@ -669,5 +734,6 @@ export async function deleteAdminWorkProductRule(ruleId: number | string) {
|
||||
errorCode: 'work_product_rule_not_found',
|
||||
})
|
||||
}
|
||||
await archiveDeletedWorkOrderTemplateProduct(rule.rule_key)
|
||||
return { deleted, retiredCostVersionCount: detached.updated }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function rule(
|
||||
sort_order: input.sort_order ?? 100,
|
||||
unit_price_fen: input.unit_price_fen ?? 0,
|
||||
reward_amount: input.reward_amount ?? 0,
|
||||
pricing_mode: input.pricing_mode ?? 'fixed',
|
||||
sharing_enabled: input.sharing_enabled ?? false,
|
||||
sharing_total_quantity: input.sharing_total_quantity ?? 1,
|
||||
sharing_unit_reward: input.sharing_unit_reward ?? 0,
|
||||
|
||||
@@ -50,7 +50,13 @@ import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-se
|
||||
export async function syncWorkerOrdersForSourceOrder(
|
||||
order: OrderRow,
|
||||
orderItems: OrderItemRow[],
|
||||
options: { source?: string; autoOnly?: boolean; sourceMetadata?: JsonObject } = {},
|
||||
options: {
|
||||
source?: string
|
||||
autoOnly?: boolean
|
||||
sourceMetadata?: JsonObject
|
||||
skipChannelAssign?: boolean
|
||||
preferredWorkRuleKey?: string
|
||||
} = {},
|
||||
) {
|
||||
const [rules, mappings] = await Promise.all([
|
||||
listWorkProductRules({ enabled: true }),
|
||||
@@ -75,9 +81,36 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
continue
|
||||
}
|
||||
|
||||
const match = resolveMatchingProductRuleDecision(order, item, rules, mappings, {
|
||||
legacyFallbackEnabled: productMatchConfig.legacyFallbackEnabled,
|
||||
})
|
||||
const preferredRuleKey = String(options.preferredWorkRuleKey || '').trim()
|
||||
const preferredRule = preferredRuleKey
|
||||
? rules.find((rule) => String(rule.rule_key || '').trim() === preferredRuleKey) || null
|
||||
: null
|
||||
const match =
|
||||
preferredRuleKey && !preferredRule
|
||||
? {
|
||||
rule: null,
|
||||
mappingId: null,
|
||||
reason: 'unmatched' as const,
|
||||
score: 0,
|
||||
candidates: [],
|
||||
diagnosis: [
|
||||
{
|
||||
dimension: 'internal_sku_channel',
|
||||
reason: 'configured_work_rule_missing',
|
||||
},
|
||||
],
|
||||
}
|
||||
: preferredRule
|
||||
? {
|
||||
rule: preferredRule,
|
||||
mappingId: null,
|
||||
reason: 'matched' as const,
|
||||
score: 1,
|
||||
candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }],
|
||||
}
|
||||
: resolveMatchingProductRuleDecision(order, item, rules, mappings, {
|
||||
legacyFallbackEnabled: productMatchConfig.legacyFallbackEnabled,
|
||||
})
|
||||
const matchContext = resolveKuaishouWorkProductMatchContext(item)
|
||||
const snapshot = safeParseJson(item.item_snapshot_json)
|
||||
const sellerId =
|
||||
@@ -198,6 +231,7 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
skuQuantity,
|
||||
unitPriceFen: Number(rule.unit_price_fen || 0),
|
||||
fixedRewardAmount: Number(rule.reward_amount || 0),
|
||||
pricingMode: rule.pricing_mode,
|
||||
sharingEnabled: rule.sharing_enabled === true,
|
||||
sharingAutoFromOrder: rule.sharing_auto_from_order === true,
|
||||
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||
@@ -309,15 +343,17 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
}
|
||||
|
||||
// 订单处理完成后立即跑一次渠道判定;失败不阻断下单,由定时任务兜底。
|
||||
try {
|
||||
await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 })
|
||||
} catch (error) {
|
||||
logWarn('[worker-order-consumer]', '履约方式归集失败,等待定时任务兜底', {
|
||||
orderId: order.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
orderItemCount: orderItems.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
if (!options.skipChannelAssign) {
|
||||
try {
|
||||
await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 })
|
||||
} catch (error) {
|
||||
logWarn('[worker-order-consumer]', '履约方式归集失败,等待定时任务兜底', {
|
||||
orderId: order.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
orderItemCount: orderItems.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
|
||||
@@ -64,6 +64,7 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined)
|
||||
autoCreate: Boolean(rule.auto_create),
|
||||
rewardAmount: Number(rule.reward_amount || 0),
|
||||
unitPriceFen: Number(rule.unit_price_fen || 0),
|
||||
pricingMode: rule.pricing_mode === 'unit' ? 'unit' : 'fixed',
|
||||
sharing: {
|
||||
enabled: rule.sharing_enabled === true,
|
||||
totalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||
|
||||
@@ -745,6 +745,7 @@ function buildProductRule(overrides: Partial<WorkProductRuleRow> = {}): WorkProd
|
||||
auto_create: true,
|
||||
reward_amount: 2500,
|
||||
unit_price_fen: 0,
|
||||
pricing_mode: 'fixed',
|
||||
required_deposit_amount: 0,
|
||||
deposit_threshold_amount: 20000,
|
||||
sharing_enabled: false,
|
||||
@@ -1624,6 +1625,7 @@ test('按件拼单未手动覆盖时,使用匹配数量与模板单价', () =>
|
||||
skuQuantity: 10,
|
||||
unitPriceFen: 20,
|
||||
fixedRewardAmount: 0,
|
||||
pricingMode: 'unit',
|
||||
sharingEnabled: true,
|
||||
sharingAutoFromOrder: true,
|
||||
sharingTotalQuantity: 1,
|
||||
@@ -1642,6 +1644,7 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => {
|
||||
skuQuantity: 10,
|
||||
unitPriceFen: 20,
|
||||
fixedRewardAmount: 0,
|
||||
pricingMode: 'unit',
|
||||
sharingEnabled: true,
|
||||
sharingAutoFromOrder: false,
|
||||
sharingTotalQuantity: 3,
|
||||
@@ -1657,7 +1660,7 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => {
|
||||
|
||||
test('模板同步价格按工单来源规格重新计算普通按件单', () => {
|
||||
const price = resolveWorkOrderTemplateSyncPrice({
|
||||
rule: buildProductRule({ unit_price_fen: 200 }),
|
||||
rule: buildProductRule({ unit_price_fen: 200, pricing_mode: 'unit' }),
|
||||
workOrder: buildWorkOrderRow({
|
||||
product_name: '指挥官秘钥15个',
|
||||
material_json: { source: { skuQuantity: 8 } },
|
||||
@@ -1667,10 +1670,39 @@ test('模板同步价格按工单来源规格重新计算普通按件单', () =>
|
||||
assert.deepEqual(price, { rewardAmount: 1600 })
|
||||
})
|
||||
|
||||
test('模板同步价格以显式计价方式为准,不按两个金额字段反推', () => {
|
||||
const workOrder = buildWorkOrderRow({ product_name: '指挥官秘钥15个' })
|
||||
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderTemplateSyncPrice({
|
||||
rule: buildProductRule({
|
||||
reward_amount: 2500,
|
||||
unit_price_fen: 200,
|
||||
pricing_mode: 'fixed',
|
||||
}),
|
||||
workOrder,
|
||||
}),
|
||||
{ rewardAmount: 2500 },
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderTemplateSyncPrice({
|
||||
rule: buildProductRule({
|
||||
reward_amount: 2500,
|
||||
unit_price_fen: 200,
|
||||
pricing_mode: 'unit',
|
||||
}),
|
||||
workOrder,
|
||||
}),
|
||||
{ rewardAmount: 3000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('模板同步价格为拼单返回新的份额单价', () => {
|
||||
const price = resolveWorkOrderTemplateSyncPrice({
|
||||
rule: buildProductRule({
|
||||
unit_price_fen: 200,
|
||||
pricing_mode: 'unit',
|
||||
sharing_enabled: true,
|
||||
sharing_auto_from_order: true,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user