优化按件拼单自动计算

This commit is contained in:
yml2213
2026-08-18 14:37:13 +08:00
parent c2305d419e
commit 7ccf3a6cee
9 changed files with 193 additions and 38 deletions
@@ -0,0 +1,7 @@
-- 036_worker_product_rule_sharing_auto.sql —— 按件计价拼单可随匹配订单自动计算。
ALTER TABLE work_product_rules
ADD COLUMN IF NOT EXISTS sharing_auto_from_order BOOLEAN NOT NULL DEFAULT false;
COMMENT ON COLUMN work_product_rules.sharing_auto_from_order
IS '按件计价且启用拼单时,是否用匹配 SKU 数量与模板单价自动生成拼单配置;false 表示使用手工配置';
@@ -150,6 +150,7 @@ export type WorkProductRuleRow = {
sharing_enabled: boolean
sharing_total_quantity: number
sharing_unit_reward: number
sharing_auto_from_order: boolean
timeout_minutes: number
timeout_policy: string
requirement_json: string | Record<string, unknown>
@@ -718,6 +718,7 @@ export async function upsertWorkProductRule(input: {
sharingEnabled?: boolean
sharingTotalQuantity?: number
sharingUnitReward?: number
sharingAutoFromOrder?: boolean
timeoutMinutes?: number
timeoutPolicy?: string
requirementJson: string
@@ -732,7 +733,7 @@ export async function upsertWorkProductRule(input: {
match_type, category_id, enabled, auto_create, reward_amount,
unit_price_fen,
required_deposit_amount, deposit_threshold_amount,
sharing_enabled, sharing_total_quantity, sharing_unit_reward,
sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_auto_from_order,
timeout_minutes, timeout_policy,
requirement_json, match_json,
sort_order, created_at, updated_at
@@ -741,10 +742,10 @@ export async function upsertWorkProductRule(input: {
$7, $8, $9, $10, $11,
$12,
$13, $14,
$15, $16, $17,
$18, $19,
$20::jsonb, $21::jsonb,
$22, $23, $24
$15, $16, $17, $18,
$19, $20,
$21::jsonb, $22::jsonb,
$23, $24, $25
)
ON CONFLICT (rule_key) DO UPDATE
SET
@@ -764,6 +765,7 @@ export async function upsertWorkProductRule(input: {
sharing_enabled = EXCLUDED.sharing_enabled,
sharing_total_quantity = EXCLUDED.sharing_total_quantity,
sharing_unit_reward = EXCLUDED.sharing_unit_reward,
sharing_auto_from_order = EXCLUDED.sharing_auto_from_order,
timeout_minutes = EXCLUDED.timeout_minutes,
timeout_policy = EXCLUDED.timeout_policy,
requirement_json = EXCLUDED.requirement_json,
@@ -790,6 +792,7 @@ export async function upsertWorkProductRule(input: {
input.sharingEnabled === true,
toPositiveInteger(input.sharingTotalQuantity, 1),
toPositiveInteger(input.sharingUnitReward, 0),
input.sharingAutoFromOrder === true,
toPositiveInteger(input.timeoutMinutes, 0),
String(input.timeoutPolicy || 'reopen').trim(),
input.requirementJson,
@@ -133,6 +133,7 @@ import {
resolveKuaishouWorkProductMatchContext,
resolveRequirementFields,
resolveSkuNameQuantity,
resolveWorkOrderRulePricing,
resolveWorkerPermissions,
} from './mappers.js'
import {
@@ -604,12 +605,47 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
})
}
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null
if (ruleId && !existingRule) {
throw createHttpError('接单模板不存在', {
statusCode: 404,
errorCode: 'work_product_rule_not_found',
})
}
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
const unitPriceFen =
payload.unitPrice === undefined && payload.unitPriceYuan === undefined
? 0
: normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0)
const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false)
const hasManualSharingConfig =
hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) ||
hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) ||
hasConfiguredSharingValue(payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan)
const sharingAutoFromOrder =
sharingEnabled &&
unitPriceFen > 0 &&
(normalizeBoolean(payload.sharingAutoFromOrder ?? payload.sharing_auto_from_order, false) ||
(!hasManualSharingConfig && existingRule?.sharing_auto_from_order !== false))
const hasManualSharingQuantity = hasConfiguredSharingValue(
payload.sharingTotalQuantity ?? payload.sharing_total_quantity,
)
const hasManualSharingUnitReward = hasConfiguredSharingValue(
payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
)
if (
sharingEnabled &&
unitPriceFen > 0 &&
!sharingAutoFromOrder &&
hasManualSharingQuantity !== hasManualSharingUnitReward
) {
throw createHttpError('按件计价的拼单覆盖值请同时填写拼单数量和拼单单价', {
statusCode: 400,
errorCode: 'work_product_rule_sharing_override_incomplete',
})
}
const sharingUnitReward = normalizeAmountFen(
payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
0,
@@ -625,7 +661,7 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
)
const resolvedSharingTotalAmount =
sharingTotalAmount > 0 ? sharingTotalAmount : sharingUnitReward * sharingTotalQuantity
if (sharingEnabled) {
if (sharingEnabled && !sharingAutoFromOrder) {
if (resolvedSharingTotalAmount <= 0) {
throw createHttpError('启用拼单时请填写拼单总价或单价', {
statusCode: 400,
@@ -633,16 +669,11 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
})
}
}
const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null
if (ruleId && !existingRule) {
throw createHttpError('接单模板不存在', {
statusCode: 404,
errorCode: 'work_product_rule_not_found',
})
}
const finalRewardAmount = sharingEnabled
? sharingAutoFromOrder
? 0
: resolvedSharingTotalAmount
: rewardAmount
const fields = normalizeRequirementFieldsFromPayload(payload)
const ruleKey = existingRule?.rule_key || randomId('rule-')
const rule = await upsertWorkProductRule({
@@ -658,7 +689,7 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
defaults.category?.id ||
null,
enabled: normalizeBoolean(payload.enabled, true),
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false),
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true),
rewardAmount: finalRewardAmount,
unitPriceFen,
// 商品规则不配置押金;工单创建后由运营按需手工填写。
@@ -667,6 +698,7 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
sharingEnabled,
sharingTotalQuantity,
sharingUnitReward,
sharingAutoFromOrder,
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
requirementJson: JSON.stringify({ fields }),
@@ -1287,6 +1319,10 @@ function resolveSharingQuantity(
return Math.max(1, fallbackQuantity)
}
function hasConfiguredSharingValue(value: unknown) {
return value !== undefined && value !== null && String(value).trim() !== ''
}
export async function submitAdminWorkOrderMaterial(
workOrderId: number | string,
payload: JsonObject = {},
@@ -2070,8 +2106,15 @@ export async function syncWorkerOrdersForSourceOrder(
const skuQuantity = resolveSkuNameQuantity(item.sku_name)
const unitPriceFen = Number(rule.unit_price_fen || 0)
// 按数量计价:单价 × SKU 名称文字中的规格数量(如 指挥官秘钥15个);未启用则用固定接单金额
const rewardAmount =
unitPriceFen > 0 ? unitPriceFen * skuQuantity : Number(rule.reward_amount || 0)
const { sharingTotalQuantity, sharingUnitReward, rewardAmount } = resolveWorkOrderRulePricing({
skuQuantity,
unitPriceFen,
fixedRewardAmount: Number(rule.reward_amount || 0),
sharingEnabled: rule.sharing_enabled === true,
sharingAutoFromOrder: rule.sharing_auto_from_order === true,
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
})
const now = nowIso()
const materialComplete = fields.every((field) => !field.required)
const workOrder = await createWorkOrder({
@@ -2087,8 +2130,8 @@ export async function syncWorkerOrdersForSourceOrder(
requiredDepositAmount: 0,
depositThresholdAmount: 0,
sharingEnabled: rule.sharing_enabled === true,
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
sharingTotalQuantity,
sharingUnitReward,
timeoutMinutes: Number(rule.timeout_minutes || 0),
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
materialJson: JSON.stringify({
@@ -200,6 +200,7 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined)
enabled: rule.sharing_enabled === true,
totalQuantity: Number(rule.sharing_total_quantity || 1),
unitReward: Number(rule.sharing_unit_reward || 0),
autoFromOrder: rule.sharing_auto_from_order === true,
},
timeoutMinutes: Number(rule.timeout_minutes || 0),
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
@@ -990,6 +991,32 @@ export function resolveSkuNameQuantity(productName: unknown): number {
return 1
}
export function resolveWorkOrderRulePricing(input: {
skuQuantity: number
unitPriceFen: number
fixedRewardAmount: number
sharingEnabled: boolean
sharingAutoFromOrder: boolean
sharingTotalQuantity: number
sharingUnitReward: number
}) {
const skuQuantity = Math.max(1, Number(input.skuQuantity) || 1)
const unitPriceFen = Math.max(0, Number(input.unitPriceFen) || 0)
const sharingTotalQuantity = input.sharingAutoFromOrder
? skuQuantity
: Math.max(1, Number(input.sharingTotalQuantity) || 1)
const sharingUnitReward = input.sharingAutoFromOrder
? unitPriceFen
: Math.max(0, Number(input.sharingUnitReward) || 0)
const rewardAmount = input.sharingEnabled
? sharingTotalQuantity * sharingUnitReward
: unitPriceFen > 0
? unitPriceFen * skuQuantity
: Math.max(0, Number(input.fixedRewardAmount) || 0)
return { sharingTotalQuantity, sharingUnitReward, rewardAmount }
}
const CHINESE_DIGITS: Record<string, number> = {
: 0,
: 1,
@@ -20,6 +20,7 @@ import {
resolveMatchingProductRuleDecision,
resolveWorkerPermissions,
resolveSkuNameQuantity,
resolveWorkOrderRulePricing,
validateWorkerPassword,
} from './index.js'
@@ -87,6 +88,7 @@ function buildProductRule(overrides: Partial<WorkProductRuleRow> = {}): WorkProd
sharing_enabled: false,
sharing_total_quantity: 1,
sharing_unit_reward: 0,
sharing_auto_from_order: false,
timeout_minutes: 0,
timeout_policy: 'reopen',
requirement_json: {},
@@ -519,6 +521,42 @@ test('resolveSkuNameQuantity falls back to 1 when no quantity is found', () => {
assert.equal(resolveSkuNameQuantity('指挥官秘钥礼包'), 1)
})
test('按件拼单未手动覆盖时,使用匹配数量与模板单价', () => {
const pricing = resolveWorkOrderRulePricing({
skuQuantity: 10,
unitPriceFen: 20,
fixedRewardAmount: 0,
sharingEnabled: true,
sharingAutoFromOrder: true,
sharingTotalQuantity: 1,
sharingUnitReward: 0,
})
assert.deepEqual(pricing, {
sharingTotalQuantity: 10,
sharingUnitReward: 20,
rewardAmount: 200,
})
})
test('按件拼单手动覆盖数量和单价时,保留手工配置', () => {
const pricing = resolveWorkOrderRulePricing({
skuQuantity: 10,
unitPriceFen: 20,
fixedRewardAmount: 0,
sharingEnabled: true,
sharingAutoFromOrder: false,
sharingTotalQuantity: 3,
sharingUnitReward: 50,
})
assert.deepEqual(pricing, {
sharingTotalQuantity: 3,
sharingUnitReward: 50,
rewardAmount: 150,
})
})
function buildWorkOrderRow(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
return {
id: 1,
@@ -106,10 +106,16 @@ export default function ProductRulesPanel() {
rewardAmount: rule.rewardAmount / 100,
unitPrice: rule.unitPriceFen / 100,
sharingEnabled: rule.sharing?.enabled || false,
sharingTotalQuantity: rule.sharing?.totalQuantity || 1,
sharingUnitReward: (rule.sharing?.unitReward || 0) / 100,
sharingTotalAmount:
((rule.sharing?.totalQuantity || 1) * (rule.sharing?.unitReward || 0)) / 100,
sharingAutoFromOrder: rule.sharing?.autoFromOrder || false,
sharingTotalQuantity: rule.sharing?.autoFromOrder
? undefined
: rule.sharing?.totalQuantity || 1,
sharingUnitReward: rule.sharing?.autoFromOrder
? undefined
: (rule.sharing?.unitReward || 0) / 100,
sharingTotalAmount: rule.sharing?.autoFromOrder
? undefined
: ((rule.sharing?.totalQuantity || 1) * (rule.sharing?.unitReward || 0)) / 100,
timeoutMinutes: rule.timeoutMinutes || 0,
timeoutPolicy: rule.timeoutPolicy || 'reopen',
fieldsText: formatRuleFields(rule.requirement?.fields || []),
@@ -142,6 +148,7 @@ export default function ProductRulesPanel() {
pricingMode?: string
unitPrice?: number
sharingEnabled?: boolean
sharingAutoFromOrder?: boolean
sharingTotalQuantity?: number
sharingUnitReward?: number
sharingTotalAmount?: number
@@ -154,6 +161,8 @@ export default function ProductRulesPanel() {
}) {
try {
const unitMode = values.pricingMode === 'unit'
const hasManualSharingOverride =
Number(values.sharingTotalQuantity || 0) > 0 || Number(values.sharingUnitReward || 0) > 0
const { pricingMode: _pricingMode, ...payload } = values
await saveAdminWorkProductRule({
...payload,
@@ -161,6 +170,8 @@ export default function ProductRulesPanel() {
ruleKey: editingRule?.ruleKey || '',
match: editingRule?.match,
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
sharingAutoFromOrder:
unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
})
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
resetRuleForm()
@@ -193,10 +204,16 @@ export default function ProductRulesPanel() {
rewardAmount: rule.rewardAmount / 100,
unitPrice: rule.unitPriceFen / 100,
sharingEnabled: rule.sharing?.enabled || false,
sharingTotalQuantity: rule.sharing?.totalQuantity || 1,
sharingUnitReward: (rule.sharing?.unitReward || 0) / 100,
sharingTotalAmount:
((rule.sharing?.totalQuantity || 1) * (rule.sharing?.unitReward || 0)) / 100,
sharingAutoFromOrder: rule.sharing?.autoFromOrder || false,
sharingTotalQuantity: rule.sharing?.autoFromOrder
? undefined
: rule.sharing?.totalQuantity || 1,
sharingUnitReward: rule.sharing?.autoFromOrder
? undefined
: (rule.sharing?.unitReward || 0) / 100,
sharingTotalAmount: rule.sharing?.autoFromOrder
? undefined
: ((rule.sharing?.totalQuantity || 1) * (rule.sharing?.unitReward || 0)) / 100,
timeoutMinutes: rule.timeoutMinutes || 0,
timeoutPolicy: rule.timeoutPolicy || 'reopen',
fieldsText: formatRuleFields(rule.requirement?.fields || []),
@@ -340,7 +357,7 @@ export default function ProductRulesPanel() {
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong style={{ color: '#3f8600', fontSize: 13 }}>
{formatMoney(row.rewardAmount)}
{row.sharing?.autoFromOrder ? '随订单自动计算' : formatMoney(row.rewardAmount)}
</Typography.Text>
{row.unitPriceFen > 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
@@ -461,11 +478,8 @@ export default function ProductRulesPanel() {
pricingMode: 'fixed',
rewardAmount: 25,
enabled: true,
autoCreate: false,
autoCreate: true,
sharingEnabled: false,
sharingTotalQuantity: 10,
sharingUnitReward: 5,
sharingTotalAmount: 50,
fieldsText:
'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区',
}}
@@ -563,8 +577,28 @@ export default function ProductRulesPanel() {
</div>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue('sharingEnabled') ? (
{({ getFieldValue }) => {
const sharingEnabled = getFieldValue('sharingEnabled')
const unitMode = getFieldValue('pricingMode') === 'unit'
if (!sharingEnabled) return null
if (unitMode) {
return (
<div className="sharing-box-inline">
<Typography.Text type="secondary" className="span-2">
SKU
</Typography.Text>
<Form.Item label="拼单数量(可选)" name="sharingTotalQuantity">
<InputNumber min={1} max={100000} addonAfter="份" className="full-width" />
</Form.Item>
<Form.Item label="拼单单价(可选)" name="sharingUnitReward">
<InputNumber min={0.01} step={1} addonAfter="元" className="full-width" />
</Form.Item>
</div>
)
}
return (
<div className="sharing-box-inline">
<Form.Item
label="拼单数量"
@@ -612,8 +646,8 @@ export default function ProductRulesPanel() {
/>
</Form.Item>
</div>
) : null
}
)
}}
</Form.Item>
{/* 资料字段: 默认展开 4 行全部清晰可见,无内嵌滚动条 */}
@@ -124,6 +124,7 @@ export function saveAdminWorkProductRule(payload: {
rewardAmount?: number
unitPrice?: number
sharingEnabled?: boolean
sharingAutoFromOrder?: boolean
sharingTotalQuantity?: number
sharingUnitReward?: number
sharingTotalAmount?: number
@@ -305,6 +305,7 @@ export type WorkOrderSharingConfig = {
enabled: boolean
totalQuantity: number
unitReward: number
autoFromOrder?: boolean
}
export type WorkerAcceptance = {