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
@@ -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,
}),