新增接单模板价格同步

This commit is contained in:
yml2213
2026-08-20 20:24:00 +08:00
parent 6539e0fb21
commit e05a6797a3
9 changed files with 437 additions and 3 deletions
@@ -0,0 +1,25 @@
-- 045_work_order_source_product_rule.sql —— 记录工单的来源接单模板,支持后续按模板同步价格。
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS product_rule_id BIGINT
REFERENCES work_product_rules(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_work_orders_product_rule_open
ON work_orders(product_rule_id, id DESC)
WHERE status = 'open';
-- 历史自动建单已在 source_synced 事件中记录 ruleKey,迁移时尽可能补齐关联。
UPDATE work_orders wo
SET product_rule_id = wpr.id
FROM work_product_rules wpr
WHERE wo.product_rule_id IS NULL
AND EXISTS (
SELECT 1
FROM work_order_events event
WHERE event.work_order_id = wo.id
AND event.event_type = 'source_synced'
AND event.payload_json ->> 'ruleKey' = wpr.rule_key
);
COMMENT ON COLUMN work_orders.product_rule_id
IS '自动建单时命中的接单模板;用于模板价格同步,删除模板后保留工单但置空';
@@ -260,6 +260,8 @@ export type WorkOrderShareRow = {
export type WorkOrderRow = {
id: number
work_order_no: string
/** 自动建单命中的接单模板;历史或人工工单可能为空。 */
product_rule_id?: number | null
order_id: number | null
order_item_id: number | null
task_id: number | null
@@ -411,6 +413,7 @@ export type CreateWorkerInput = {
export type CreateWorkOrderInput = {
workOrderNo: string
productRuleId?: number | null
orderId?: number | null
orderItemId?: number | null
taskId?: number | null
@@ -867,6 +867,183 @@ export async function getWorkProductRuleById(
return result.rows[0] || null
}
/** 查询仍在接单大厅、且明确来源于指定模板的工单。 */
export async function listOpenWorkOrdersByProductRuleId(
ruleId: number | string,
): Promise<WorkOrderRow[]> {
const result = await query<WorkOrderRow>(
`${WORK_ORDER_SELECT}
WHERE wo.product_rule_id = $1
AND wo.status = 'open'
ORDER BY wo.id ASC`,
[Number(ruleId)],
)
return result.rows
}
export type SyncOpenWorkOrderPriceResult = {
updated: boolean
reason: 'updated' | 'price_unchanged' | 'not_open' | 'assigned' | 'sharing_filled'
workOrderId: number
sharingPartial: boolean
workerIds: number[]
}
/** 拼单改价时保留已加入份额的报酬,只按新价计算剩余份额。 */
export function resolveOpenSharingSyncReward(input: {
totalQuantity: number
joinedQuantity: number
lockedShareReward: number
nextUnitReward: number
}) {
const totalQuantity = toPositiveInteger(input.totalQuantity, 1)
const joinedQuantity = Math.min(totalQuantity, Math.max(0, Number(input.joinedQuantity || 0)))
return (
Math.max(0, Number(input.lockedShareReward || 0)) +
(totalQuantity - joinedQuantity) * toPositiveInteger(input.nextUnitReward, 0)
)
}
/**
* 同步大厅工单的模板价格。
* 已加入的拼单份额报酬是合同快照,只更新尚未加入的剩余份额价格。
*/
export async function syncOpenWorkOrderPriceFromRule(input: {
workOrderId: number
productRuleId: number
rewardAmount: number
sharingUnitReward?: number
actorName: string
now: string
}): Promise<SyncOpenWorkOrderPriceResult> {
return withTransaction(async (client) => {
const orderResult = await client.query<WorkOrderRow>(
`SELECT *
FROM work_orders
WHERE id = $1 AND product_rule_id = $2
FOR UPDATE`,
[input.workOrderId, input.productRuleId],
)
const workOrder = orderResult.rows[0] || null
if (!workOrder || workOrder.status !== 'open') {
return {
updated: false,
reason: 'not_open',
workOrderId: input.workOrderId,
sharingPartial: false,
workerIds: [],
}
}
if (workOrder.assigned_worker_id) {
return {
updated: false,
reason: 'assigned',
workOrderId: input.workOrderId,
sharingPartial: false,
workerIds: [],
}
}
const sharesResult = await client.query<WorkOrderShareRow>(
`SELECT *
FROM work_order_shares
WHERE work_order_id = $1
FOR UPDATE`,
[workOrder.id],
)
const activeShares = sharesResult.rows.filter((share) => share.status !== 'cancelled')
const joinedQuantity = activeShares.reduce(
(sum, share) => sum + Math.max(0, Number(share.quantity || 0)),
0,
)
const workerIds = [
...new Set(
activeShares.map((share) => Number(share.worker_id)).filter((workerId) => workerId > 0),
),
]
const sharingPartial = workOrder.sharing_enabled === true && joinedQuantity > 0
let nextRewardAmount = toPositiveInteger(input.rewardAmount, 0)
let nextSharingUnitReward = Number(workOrder.sharing_unit_reward || 0)
if (workOrder.sharing_enabled === true) {
const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1)
if (joinedQuantity >= totalQuantity) {
return {
updated: false,
reason: 'sharing_filled',
workOrderId: Number(workOrder.id),
sharingPartial,
workerIds,
}
}
nextSharingUnitReward = toPositiveInteger(input.sharingUnitReward, 0)
const lockedShareReward = activeShares.reduce(
(sum, share) => sum + Math.max(0, Number(share.share_reward || 0)),
0,
)
nextRewardAmount = resolveOpenSharingSyncReward({
totalQuantity,
joinedQuantity,
lockedShareReward,
nextUnitReward: nextSharingUnitReward,
})
}
const unchanged =
Number(workOrder.reward_amount || 0) === nextRewardAmount &&
(!workOrder.sharing_enabled ||
Number(workOrder.sharing_unit_reward || 0) === nextSharingUnitReward)
if (unchanged) {
return {
updated: false,
reason: 'price_unchanged',
workOrderId: Number(workOrder.id),
sharingPartial,
workerIds,
}
}
await client.query(
`UPDATE work_orders
SET reward_amount = $1,
sharing_unit_reward = $2,
updated_at = $3
WHERE id = $4`,
[nextRewardAmount, nextSharingUnitReward, input.now, workOrder.id],
)
await client.query(
`INSERT INTO work_order_events (
work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at
) VALUES ($1, 'admin', $2, 'price_synced_from_rule', $3, $3, $4::jsonb, $5)`,
[
workOrder.id,
input.actorName,
workOrder.status,
JSON.stringify({
productRuleId: input.productRuleId,
previousRewardAmount: Number(workOrder.reward_amount || 0),
rewardAmount: nextRewardAmount,
previousSharingUnitReward: Number(workOrder.sharing_unit_reward || 0),
sharingUnitReward: nextSharingUnitReward,
joinedQuantity,
lockedShareReward: activeShares.reduce(
(sum, share) => sum + Math.max(0, Number(share.share_reward || 0)),
0,
),
}),
input.now,
],
)
return {
updated: true,
reason: 'updated',
workOrderId: Number(workOrder.id),
sharingPartial,
workerIds,
}
})
}
export async function deleteWorkProductRule(
ruleId: number | string,
): Promise<{ deleted: boolean }> {
@@ -986,19 +1163,20 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
const result = await query<{ id: number }>(
`
INSERT INTO work_orders (
work_order_no, order_id, order_item_id, task_id, platform_order_id,
work_order_no, product_rule_id, order_id, order_item_id, task_id, platform_order_id,
product_name, category_id, status, reward_amount, required_deposit_amount,
deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
sharing_unit_reward, timeout_minutes, timeout_policy,
material_json, requirement_json, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17::jsonb, $18::jsonb, $19, $20
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17, $18::jsonb, $19::jsonb, $20, $21
)
RETURNING id
`,
[
input.workOrderNo,
input.productRuleId || null,
input.orderId || null,
input.orderItemId || null,
input.taskId || null,
@@ -53,6 +53,7 @@ import {
saveAdminWorkCategory,
saveAdminWorkProductRuleMapping,
saveAdminWorkProductRule,
syncAdminWorkProductRulePrice,
testAdminKuaishouProductMatch,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
@@ -186,6 +187,29 @@ router.post(
}),
)
router.post(
'/worker-platform/product-rules/:ruleId/sync-price',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
syncAdminWorkProductRulePrice(
String(req.params.ruleId || ''),
req.adminSession?.username || '',
),
{
successMessage: '模板价格已同步',
errorMessage: '同步模板价格失败',
scope: '[admin/worker-platform/product-rules/:ruleId/sync-price]',
audit: (req, data) => ({
action: 'work_product_rule_price_synced',
targetType: 'work_product_rule',
targetId: String(req.params.ruleId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.delete(
'/worker-platform/product-rules/:ruleId',
requireAdminRoles(['admin', 'operator']),
@@ -36,6 +36,7 @@ import {
listWorkerFinanceRequests,
listWorkOrders,
listWorkOrderEventsByOrderId,
listOpenWorkOrdersByProductRuleId,
listWorkProductRules,
listWorkProductRuleMappings,
listWorkProductRuleMappingsPage,
@@ -47,6 +48,7 @@ import {
listWorkOrderShares,
listWorkOrderSharesByOrderIds,
sumPendingUnfreezeByOrderIds,
syncOpenWorkOrderPriceFromRule,
resolveProblemWorkOrder,
reviewWorkerFinanceRequest,
reopenCancelledWorkOrder,
@@ -67,6 +69,7 @@ import {
type WorkOrderRow,
type WorkProductMatchLogRow,
type WorkProductRuleMappingRow,
type WorkProductRuleRow,
type WorkerWithdrawalAccountRow,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
@@ -881,6 +884,103 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
return { rule: mapWorkProductRule(rule) }
}
/** 根据工单创建时的规格,计算当前模板可同步的价格。 */
export function resolveWorkOrderTemplateSyncPrice(input: {
rule: WorkProductRuleRow
workOrder: WorkOrderRow
}): { rewardAmount: number; sharingUnitReward?: number } | null {
const material = safeParseJson(input.workOrder.material_json)
const source = safeParseJson(material.source)
const sourceSkuQuantity = Number(source.skuQuantity || 0)
const skuQuantity =
Number.isSafeInteger(sourceSkuQuantity) && sourceSkuQuantity > 0
? sourceSkuQuantity
: resolveSkuNameQuantity(input.workOrder.product_name)
const pricing = resolveWorkOrderRulePricing({
skuQuantity,
unitPriceFen: Number(input.rule.unit_price_fen || 0),
fixedRewardAmount: Number(input.rule.reward_amount || 0),
sharingEnabled: input.rule.sharing_enabled === true,
sharingAutoFromOrder: input.rule.sharing_auto_from_order === true,
sharingTotalQuantity: Number(input.rule.sharing_total_quantity || 1),
sharingUnitReward: Number(input.rule.sharing_unit_reward || 0),
})
if (input.workOrder.sharing_enabled === true) {
if (input.rule.sharing_enabled !== true || pricing.sharingUnitReward <= 0) {
return null
}
return {
rewardAmount: pricing.rewardAmount,
sharingUnitReward: pricing.sharingUnitReward,
}
}
return pricing.rewardAmount > 0 ? { rewardAmount: pricing.rewardAmount } : null
}
/** 将指定模板的当前价格同步到仍在接单大厅的来源工单。 */
export async function syncAdminWorkProductRulePrice(ruleId: number | string, actorName = '') {
const normalizedRuleId = normalizeOptionalId(ruleId)
const rule = normalizedRuleId ? await getWorkProductRuleById(normalizedRuleId) : null
if (!rule) {
throw createHttpError('接单模板不存在', {
statusCode: 404,
errorCode: 'work_product_rule_not_found',
})
}
const candidates = await listOpenWorkOrdersByProductRuleId(rule.id)
const result = {
candidateCount: candidates.length,
updatedCount: 0,
updatedNormalCount: 0,
updatedPartialSharingCount: 0,
skippedInvalidPricingCount: 0,
skippedUnchangedCount: 0,
skippedUnavailableCount: 0,
}
const now = nowIso()
for (const workOrder of candidates) {
const price = resolveWorkOrderTemplateSyncPrice({ rule, workOrder })
if (!price) {
result.skippedInvalidPricingCount += 1
continue
}
const synced = await syncOpenWorkOrderPriceFromRule({
workOrderId: Number(workOrder.id),
productRuleId: Number(rule.id),
rewardAmount: price.rewardAmount,
...(price.sharingUnitReward === undefined
? {}
: { sharingUnitReward: price.sharingUnitReward }),
actorName,
now,
})
if (!synced.updated) {
if (synced.reason === 'price_unchanged') {
result.skippedUnchangedCount += 1
} else {
result.skippedUnavailableCount += 1
}
continue
}
result.updatedCount += 1
if (synced.sharingPartial) {
result.updatedPartialSharingCount += 1
} else {
result.updatedNormalCount += 1
}
publishWorkOrderRealtimeChange({
workOrderId: synced.workOrderId,
workerIds: synced.workerIds,
hallChanged: true,
})
}
return { rule: mapWorkProductRule(rule), ...result }
}
export async function deleteAdminWorkProductRule(ruleId: number | string) {
const normalizedRuleId = normalizeOptionalId(ruleId)
if (!normalizedRuleId) {
@@ -2584,6 +2684,7 @@ export async function syncWorkerOrdersForSourceOrder(
const materialComplete = fields.every((field) => !field.required)
const workOrder = await createWorkOrder({
workOrderNo: randomId('WO'),
productRuleId: Number(rule.id),
orderId: Number(order.id),
orderItemId: Number(item.id),
taskId: null,
@@ -11,6 +11,7 @@ import type {
import {
resolveWorkOrderShareCancellationStatus,
resolveWorkOrderShareJoinQuantity,
resolveOpenSharingSyncReward,
} from '../../repositories/worker-platform/index.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
@@ -30,6 +31,7 @@ import {
resolveWorkerPermissions,
resolveSkuNameQuantity,
resolveWorkOrderRulePricing,
resolveWorkOrderTemplateSyncPrice,
resolveWorkOrderSharingEnabled,
validateWorkerPassword,
} from './index.js'
@@ -592,6 +594,47 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => {
})
})
test('模板同步价格按工单来源规格重新计算普通按件单', () => {
const price = resolveWorkOrderTemplateSyncPrice({
rule: buildProductRule({ unit_price_fen: 200 }),
workOrder: buildWorkOrderRow({
product_name: '指挥官秘钥15个',
material_json: { source: { skuQuantity: 8 } },
}),
})
assert.deepEqual(price, { rewardAmount: 1600 })
})
test('模板同步价格为拼单返回新的份额单价', () => {
const price = resolveWorkOrderTemplateSyncPrice({
rule: buildProductRule({
unit_price_fen: 200,
sharing_enabled: true,
sharing_auto_from_order: true,
}),
workOrder: buildWorkOrderRow({
product_name: '指挥官秘钥15个',
sharing_enabled: true,
sharing_total_quantity: 15,
}),
})
assert.deepEqual(price, { rewardAmount: 3000, sharingUnitReward: 200 })
})
test('部分拼单同步后总报酬保留已加入份额的旧价格', () => {
assert.equal(
resolveOpenSharingSyncReward({
totalQuantity: 10,
joinedQuantity: 4,
lockedShareReward: 4000,
nextUnitReward: 1200,
}),
11_200,
)
})
test('模板启用拼单但实际只有一份时生成普通工单', () => {
assert.equal(resolveWorkOrderSharingEnabled(true, 1), false)
})