Merge branch 'main' into dev

This commit is contained in:
2026-11-03 10:33:10 +08:00
121 changed files with 5265 additions and 1629 deletions
@@ -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'
@@ -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,
}),