From f2edb7f8959ef036abf889ee76ea6ff1ddce8002 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 14 Aug 2026 18:25:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=89=A9=E5=93=81=E8=A7=84=E5=88=99?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E6=95=B0=E9=87=8F=E8=AE=A1=E4=BB=B7?= =?UTF-8?q?=EF=BC=88=E4=BA=8C=E6=AC=A1=E5=8C=B9=E9=85=8DSKU=E6=96=87?= =?UTF-8?q?=E5=AD=97=E6=95=B0=E9=87=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 规则新增单价(unit_price_fen,0 关闭),开启后接单总价 = 单价 × 商品名称文字中的规格数量 - 新增 resolveSkuNameQuantity:支持阿拉伯数字与中文数字(如 指挥官秘钥15个 → 15、三个 → 3) - 建单同步时按文字数量计算奖励,skuQuantity 记入工单 material.source 便于核对 - 规则表单新增计价方式(固定金额/按数量×单价),列表展示单价 - 新增 3 个数量解析单元测试(269 全通过) --- .../018_work_product_rule_unit_price.sql | 5 ++ .../src/repositories/worker-platform/types.ts | 1 + .../worker-platform/work-order-repo.ts | 15 ++-- .../services/worker-platform/admin-service.ts | 13 +++- .../src/services/worker-platform/mappers.ts | 64 ++++++++++++++++- .../worker-platform-service.test.ts | 21 ++++++ .../pages/admin/panels/ProductRulesPanel.tsx | 68 ++++++++++++++++--- .../src/services/admin/worker-platform.ts | 1 + apps/frontend/src/types/worker-platform.ts | 1 + 9 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 apps/backend/src/db/migrations/018_work_product_rule_unit_price.sql diff --git a/apps/backend/src/db/migrations/018_work_product_rule_unit_price.sql b/apps/backend/src/db/migrations/018_work_product_rule_unit_price.sql new file mode 100644 index 00000000..bd494f71 --- /dev/null +++ b/apps/backend/src/db/migrations/018_work_product_rule_unit_price.sql @@ -0,0 +1,5 @@ +-- 物品规则支持按数量计价:unit_price_fen > 0 时,工单接单总价 = 单价 × SKU 名称文字中的规格数量(如 指挥官秘钥15个 → 15);0 表示按固定接单金额。 +ALTER TABLE work_product_rules + ADD COLUMN unit_price_fen INTEGER NOT NULL DEFAULT 0; + +COMMENT ON COLUMN work_product_rules.unit_price_fen IS '按数量计价单价(分),0 表示未启用,按固定接单金额;数量取自商品名称文字(如 15个/三个)'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 772ddb77..0a7fc1e0 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -119,6 +119,7 @@ export type WorkProductRuleRow = { enabled: boolean auto_create: boolean reward_amount: number + unit_price_fen: number required_deposit_amount: number deposit_threshold_amount: number sharing_enabled: boolean diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index 3b375269..ab30ae4a 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -413,6 +413,7 @@ export async function upsertWorkProductRule(input: { enabled: boolean autoCreate: boolean rewardAmount: number + unitPriceFen?: number requiredDepositAmount: number depositThresholdAmount: number sharingEnabled?: boolean @@ -429,6 +430,7 @@ export async function upsertWorkProductRule(input: { INSERT INTO work_product_rules ( rule_key, provider, platform, shop_id, sku_code, product_name, 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, timeout_minutes, timeout_policy, @@ -437,11 +439,12 @@ export async function upsertWorkProductRule(input: { ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, - $12, $13, - $14, $15, $16, - $17, $18, - $19::jsonb, - $20, $21, $22 + $12, + $13, $14, + $15, $16, $17, + $18, $19, + $20::jsonb, + $21, $22, $23 ) ON CONFLICT (rule_key) DO UPDATE SET @@ -455,6 +458,7 @@ export async function upsertWorkProductRule(input: { enabled = EXCLUDED.enabled, auto_create = EXCLUDED.auto_create, reward_amount = EXCLUDED.reward_amount, + unit_price_fen = EXCLUDED.unit_price_fen, required_deposit_amount = EXCLUDED.required_deposit_amount, deposit_threshold_amount = EXCLUDED.deposit_threshold_amount, sharing_enabled = EXCLUDED.sharing_enabled, @@ -479,6 +483,7 @@ export async function upsertWorkProductRule(input: { input.enabled, input.autoCreate, input.rewardAmount, + Math.max(0, toPositiveInteger(input.unitPriceFen, 0)), input.requiredDepositAmount, input.depositThresholdAmount, input.sharingEnabled === true, diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 2af2e1fc..87cd1455 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -89,7 +89,7 @@ import { saveWorkerFinanceConfig, } from './worker-finance-config-service.js' -import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, normalizeUploadedFiles, normalizeWorkerType, resolveFreezeDepositAmount, resolveMatchingProductRule, resolveRequirementFields, resolveWorkerPermissions } from './mappers.js' +import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, normalizeUploadedFiles, normalizeWorkerType, resolveFreezeDepositAmount, resolveMatchingProductRule, resolveRequirementFields, resolveSkuNameQuantity, resolveWorkerPermissions } from './mappers.js' import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker, normalizeWorkOrderTimeoutPolicy } from './worker-service.js' export async function listAdminWorkerLevels() { @@ -288,6 +288,10 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { enabled: normalizeBoolean(payload.enabled, true), autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false), rewardAmount: finalRewardAmount, + unitPriceFen: + payload.unitPrice === undefined && payload.unitPriceYuan === undefined + ? 0 + : normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0), requiredDepositAmount, depositThresholdAmount, sharingEnabled, @@ -1177,7 +1181,11 @@ export async function syncWorkerOrdersForSourceOrder( } const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields) - const rewardAmount = Number(rule.reward_amount || 0) + 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 depositThresholdAmount = Number( rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, ) @@ -1213,6 +1221,7 @@ export async function syncWorkerOrdersForSourceOrder( shopName: order.shop_name, skuCode: item.sku_code, skuName: item.sku_name, + skuQuantity, quantity: Number(item.quantity || 1), orderAmountFen: Number(order.total_amount || 0), currency: order.currency || 'CNY', diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 6a22e10e..1ed87211 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -238,6 +238,7 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) enabled: Boolean(rule.enabled), autoCreate: Boolean(rule.auto_create), rewardAmount: Number(rule.reward_amount || 0), + unitPriceFen: Number(rule.unit_price_fen || 0), requiredDepositAmount: Number(rule.required_deposit_amount || 0), depositThresholdAmount: Number( rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, @@ -664,8 +665,67 @@ export function resolveMatchingProductRule( ) } -export function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) { - const ruleText = String(ruleValue || '') +/** + * 从商品名称文字中解析规格数量(二次匹配数量)。 + * 支持阿拉伯数字与中文数字,如:指挥官秘钥1个 → 1、指挥官秘钥15个 → 15、指挥官秘钥三个 → 3。 + * 解析不到时返回 1。 + */ +export function resolveSkuNameQuantity(productName: unknown): number { + const text = String(productName || '').trim() + if (!text) return 1 + + const arabicMatch = text.match(/(\d+)\s*(?:个|份|张|枚)/) + if (arabicMatch) { + const parsed = Number(arabicMatch[1]) + if (Number.isSafeInteger(parsed) && parsed > 0) return parsed + } + + const chineseMatch = text.match( + /([零一二三四五六七八九十百千两]+)\s*(?:个|份|张|枚)/, + ) + if (chineseMatch?.[1]) { + const parsed = parseChineseNumber(chineseMatch[1]) + if (parsed > 0) return parsed + } + + return 1 +} + +const CHINESE_DIGITS: Record = { + 零: 0, + 一: 1, + 二: 2, + 两: 2, + 三: 3, + 四: 4, + 五: 5, + 六: 6, + 七: 7, + 八: 8, + 九: 9, +} + +function parseChineseNumber(text: string): number { + let section = 0 + let digit = 0 + for (const char of text) { + if (char === '十') { + section += (digit || 1) * 10 + digit = 0 + } else if (char === '百') { + section += (digit || 1) * 100 + digit = 0 + } else if (char === '千') { + section += (digit || 1) * 1000 + digit = 0 + } else if (char in CHINESE_DIGITS) { + digit = CHINESE_DIGITS[char] ?? 0 + } + } + return section + digit +} + +export function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) { const ruleText = String(ruleValue || '') .trim() .toLowerCase() if (!ruleText) { diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 96aaa788..1ff55621 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -6,6 +6,7 @@ import { assertWorkerLoginAllowed, normalizeRequirementFields, resolveCollectSubmitTargetWorkOrder, + resolveSkuNameQuantity, } from './index.js' function buildWorkerUserRow(overrides: Partial = {}): WorkerUserRow { @@ -98,6 +99,26 @@ test('normalizeRequirementFields default template marks serverZone optional with assert.deepEqual(serverZone?.options, ['QQ区', '微信区']) }) +test('resolveSkuNameQuantity parses arabic quantities from sku names', () => { + assert.equal(resolveSkuNameQuantity('指挥官秘钥1个'), 1) + assert.equal(resolveSkuNameQuantity('指挥官秘钥15个'), 15) + assert.equal(resolveSkuNameQuantity('指挥官秘钥2个'), 2) + assert.equal(resolveSkuNameQuantity('指挥官秘钥10个'), 10) +}) + +test('resolveSkuNameQuantity parses chinese numeral quantities from sku names', () => { + assert.equal(resolveSkuNameQuantity('指挥官秘钥三个'), 3) + assert.equal(resolveSkuNameQuantity('指挥官秘钥十五个'), 15) + assert.equal(resolveSkuNameQuantity('指挥官秘钥二十个'), 20) + assert.equal(resolveSkuNameQuantity('指挥官秘钥三十个'), 30) +}) + +test('resolveSkuNameQuantity falls back to 1 when no quantity is found', () => { + assert.equal(resolveSkuNameQuantity('指挥官秘钥'), 1) + assert.equal(resolveSkuNameQuantity(''), 1) + assert.equal(resolveSkuNameQuantity('指挥官秘钥礼包'), 1) +}) + function buildWorkOrderRow( overrides: Partial = {}, diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index 33168169..5dc0d057 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -22,6 +22,7 @@ import { InputNumber, Modal, Popconfirm, + Radio, Select, Space, Statistic, @@ -114,6 +115,8 @@ export default function ProductRulesPanel() { rewardAmount?: number requiredDepositAmount?: number depositThresholdAmount?: number + pricingMode?: string + unitPrice?: number sharingEnabled?: boolean sharingTotalQuantity?: number sharingUnitReward?: number @@ -123,11 +126,15 @@ export default function ProductRulesPanel() { fieldsText?: string enabled?: boolean autoCreate?: boolean + sortOrder?: number }) { try { + const unitMode = values.pricingMode === 'unit' + const { pricingMode: _pricingMode, ...payload } = values await saveAdminWorkProductRule({ - ...values, + ...payload, ruleId: editingRule?.ruleId, + unitPrice: unitMode ? Number(values.unitPrice || 0) : 0, }) message.success(editingRule ? '物品规则已更新' : '物品规则已创建') resetRuleForm() @@ -160,7 +167,9 @@ export default function ProductRulesPanel() { productName: rule.productName, matchType: rule.matchType, categoryId: rule.categoryId || undefined, + pricingMode: rule.unitPriceFen > 0 ? 'unit' : 'fixed', rewardAmount: rule.rewardAmount / 100, + unitPrice: rule.unitPriceFen / 100, requiredDepositAmount: rule.requiredDepositAmount / 100, depositThresholdAmount: rule.depositThresholdAmount / 100, sharingEnabled: rule.sharing?.enabled || false, @@ -250,8 +259,17 @@ export default function ProductRulesPanel() { { title: '分类', width: 140, render: (_, row) => row.categoryName || '-' }, { title: '接单金额', - width: 120, - render: (_, row) => formatMoney(row.rewardAmount), + width: 150, + render: (_, row) => ( +
+ {formatMoney(row.rewardAmount)} + {row.unitPriceFen > 0 ? ( + + 按数量计价:{formatMoney(row.unitPriceFen)} × 数量 + + ) : null} +
+ ), }, { title: '押金', @@ -333,6 +351,7 @@ export default function ProductRulesPanel() { initialValues={{ platform: 'kuaishou', matchType: 'contains', + pricingMode: 'fixed', rewardAmount: 25, depositThresholdAmount: 200, enabled: true, @@ -405,14 +424,47 @@ export default function ProductRulesPanel() { )} /> - - + + + {({ getFieldValue }) => + getFieldValue('pricingMode') === 'unit' ? ( + + + + ) : ( + + + + ) + } +