feat(打手): 支持个人追加免押额度
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
-- 065_worker_personal_deposit_free_amount.sql —— 单个打手的免押额度覆盖等级配置。
|
||||
|
||||
ALTER TABLE worker_users
|
||||
ADD COLUMN IF NOT EXISTS personal_deposit_free_amount INTEGER;
|
||||
|
||||
COMMENT ON COLUMN worker_users.personal_deposit_free_amount IS '个人追加免押额度,单位分;与等级免押额度叠加,NULL/0 表示未追加';
|
||||
@@ -16,6 +16,8 @@ export type WorkerUserRow = {
|
||||
display_name: string
|
||||
phone: string
|
||||
level_id: number | null
|
||||
/** 个人追加免押额度(分),与等级额度叠加。 */
|
||||
personal_deposit_free_amount?: number | null
|
||||
status: string
|
||||
worker_type: string
|
||||
review_note: string
|
||||
|
||||
@@ -95,12 +95,37 @@ export function resolveWorkOrderShareJoinQuantity(input: {
|
||||
return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算打手当前累计拼单份额实际需要冻结的押金。
|
||||
* 等级与个人叠加后的免押额度针对该打手在本工单的累计份额生效;已经冻结的金额不自动回退。
|
||||
*/
|
||||
export function resolveWorkOrderShareDepositAmount(input: {
|
||||
requiredDepositAmount: number
|
||||
totalQuantity: number
|
||||
nextQuantity: number
|
||||
depositFreeAmount?: number
|
||||
currentFrozenDeposit?: number
|
||||
}) {
|
||||
const totalQuantity = Math.max(1, Number(input.totalQuantity || 0))
|
||||
const nextRequiredDeposit = Math.round(
|
||||
(Math.max(0, Number(input.requiredDepositAmount || 0)) * Math.max(0, Number(input.nextQuantity || 0))) /
|
||||
totalQuantity,
|
||||
)
|
||||
const requiredShareDeposit = Math.max(
|
||||
0,
|
||||
nextRequiredDeposit - Math.max(0, Number(input.depositFreeAmount || 0)),
|
||||
)
|
||||
return Math.max(Math.max(0, Number(input.currentFrozenDeposit || 0)), requiredShareDeposit)
|
||||
}
|
||||
|
||||
export async function joinWorkOrderShare(input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
quantity: number
|
||||
/** 立即抢单时在事务锁内承接全部剩余份数。 */
|
||||
takeRemaining?: boolean
|
||||
/** 当前打手的有效免押额度(分),按其本次累计拼单份额抵扣。 */
|
||||
depositFreeAmount?: number
|
||||
maxActiveOrders: number
|
||||
hallCandidateLimit?: number
|
||||
hallCategoryLimits?: Record<string, number>
|
||||
@@ -192,9 +217,15 @@ export async function joinWorkOrderShare(input: {
|
||||
const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0)
|
||||
const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity
|
||||
const shareReward = nextQuantity * unitReward
|
||||
const nextShareDeposit = Math.round(
|
||||
(Number(workOrder.required_deposit_amount || 0) * nextQuantity) / totalQuantity,
|
||||
)
|
||||
const nextShareDeposit = resolveWorkOrderShareDepositAmount({
|
||||
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||
totalQuantity,
|
||||
nextQuantity,
|
||||
...(input.depositFreeAmount === undefined
|
||||
? {}
|
||||
: { depositFreeAmount: input.depositFreeAmount }),
|
||||
currentFrozenDeposit: Number(existingShare?.share_deposit || 0),
|
||||
})
|
||||
const depositToFreeze = Math.max(
|
||||
0,
|
||||
nextShareDeposit - Number(existingShare?.share_deposit || 0),
|
||||
|
||||
@@ -145,6 +145,7 @@ export async function updateWorkerUser(
|
||||
| 'frozen_reason'
|
||||
| 'frozen_at'
|
||||
| 'level_id'
|
||||
| 'personal_deposit_free_amount'
|
||||
| 'session_version'
|
||||
| 'updated_at'
|
||||
| 'reviewed_at'
|
||||
@@ -165,10 +166,11 @@ export async function updateWorkerUser(
|
||||
frozen_reason = $4,
|
||||
frozen_at = $5,
|
||||
level_id = $6,
|
||||
session_version = $7,
|
||||
reviewed_at = $8,
|
||||
updated_at = $9
|
||||
WHERE id = $10
|
||||
personal_deposit_free_amount = $7,
|
||||
session_version = $8,
|
||||
reviewed_at = $9,
|
||||
updated_at = $10
|
||||
WHERE id = $11
|
||||
`,
|
||||
[
|
||||
next.status,
|
||||
@@ -177,6 +179,7 @@ export async function updateWorkerUser(
|
||||
next.frozen_reason || '',
|
||||
next.frozen_at || null,
|
||||
next.level_id || null,
|
||||
next.personal_deposit_free_amount ?? null,
|
||||
Number(next.session_version || 1),
|
||||
next.reviewed_at || null,
|
||||
next.updated_at,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resetAdminWorkerPassword,
|
||||
reviewAdminWorkerUser,
|
||||
saveAdminWorkerWithdrawalAccount,
|
||||
setAdminWorkerPersonalDepositFreeAmount,
|
||||
} from '../../../services/worker-platform/index.js'
|
||||
import { createJsonHandler, requireAdminRoles } from '../session.js'
|
||||
|
||||
@@ -99,6 +100,26 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/workers/:workerId/personal-deposit-free-amount',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
setAdminWorkerPersonalDepositFreeAmount(String(req.params.workerId || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '打手个人免押额度已更新',
|
||||
errorMessage: '更新个人免押额度失败',
|
||||
scope: '[admin/worker-platform/workers/:workerId/personal-deposit-free-amount]',
|
||||
audit: (req, data) => ({
|
||||
action: 'worker_personal_deposit_free_amount_updated',
|
||||
targetType: 'worker',
|
||||
targetId: String(req.params.workerId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/workers/:workerId/wallet-credit',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
|
||||
@@ -47,5 +47,6 @@ export {
|
||||
listAdminWorkerUsers,
|
||||
resetAdminWorkerPassword,
|
||||
reviewAdminWorkerUser,
|
||||
setAdminWorkerPersonalDepositFreeAmount,
|
||||
} from './admin-worker-user-service.js'
|
||||
export { syncWorkerOrdersForSourceOrder } from './sync-worker-orders-service.js'
|
||||
|
||||
@@ -13,6 +13,7 @@ import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
|
||||
import {
|
||||
hashWorkerPassword,
|
||||
mapWorkerUser,
|
||||
normalizeAmountFen,
|
||||
normalizeOptionalId,
|
||||
normalizeReviewStatus,
|
||||
normalizeSessionVersion,
|
||||
@@ -89,6 +90,30 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
|
||||
return { worker: mapWorkerUser(updated || worker) }
|
||||
}
|
||||
|
||||
/** 设置单个打手的追加免押额度,会与该打手等级的免押额度叠加。 */
|
||||
export async function setAdminWorkerPersonalDepositFreeAmount(
|
||||
workerId: number | string,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const worker = await getRequiredWorker(workerId)
|
||||
const rawAmount = payload.depositFreeAmount ?? payload.deposit_free_amount
|
||||
const clearPersonalAmount = rawAmount === null || rawAmount === undefined || String(rawAmount).trim() === ''
|
||||
if (!clearPersonalAmount) {
|
||||
const amount = Number(rawAmount)
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
throw createHttpError('个人免押额度必须是不小于 0 的金额', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_personal_deposit_free_amount_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
const updated = await updateWorkerUser(worker.id, {
|
||||
personal_deposit_free_amount: clearPersonalAmount ? null : normalizeAmountFen(rawAmount, 0),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
return { worker: mapWorkerUser(updated || worker) }
|
||||
}
|
||||
|
||||
export async function resetAdminWorkerPassword(
|
||||
workerId: number | string,
|
||||
payload: JsonObject = {},
|
||||
|
||||
@@ -315,6 +315,8 @@ export function mapWorkOrderForWorker(
|
||||
freezeDepositAmount: myShare
|
||||
? Number(myShare.share_deposit || 0)
|
||||
: resolveFreezeDepositAmount(workOrder, permissions),
|
||||
/** 用于大厅拼单按当前剩余份数准确计算个人免押后的押金。 */
|
||||
depositFreeAmount: Number(permissions.depositFreeAmount || 0),
|
||||
assignedAt: myShare?.created_at || visibleOrder.assignedAt,
|
||||
submittedAt: myShare?.submitted_at || visibleOrder.submittedAt,
|
||||
acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt,
|
||||
|
||||
@@ -62,6 +62,7 @@ export function normalizeWorkerType(value: unknown): 'internal' | 'external' {
|
||||
}
|
||||
|
||||
export function mapWorkerUser(worker: WorkerUserRow) {
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
return {
|
||||
workerId: Number(worker.id),
|
||||
username: worker.username,
|
||||
@@ -85,9 +86,12 @@ export function mapWorkerUser(worker: WorkerUserRow) {
|
||||
levelId: Number(worker.level_id),
|
||||
levelKey: worker.level_key || '',
|
||||
name: worker.level_name || '',
|
||||
permissions: resolveWorkerPermissions(worker),
|
||||
permissions,
|
||||
}
|
||||
: null,
|
||||
personalDepositFreeAmount: permissions.personalDepositFreeAmount,
|
||||
levelDepositFreeAmount: permissions.levelDepositFreeAmount,
|
||||
effectiveDepositFreeAmount: permissions.depositFreeAmount,
|
||||
wallet: {
|
||||
availableAmount: Number(worker.available_amount || 0),
|
||||
frozenDepositAmount: Number(worker.frozen_deposit_amount || 0),
|
||||
|
||||
@@ -53,6 +53,7 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
||||
workerId: Number(worker.id),
|
||||
quantity: 0,
|
||||
takeRemaining: true,
|
||||
depositFreeAmount: permissions.depositFreeAmount,
|
||||
maxActiveOrders: permissions.maxActiveOrders,
|
||||
hallCandidateLimit: hallConfig.maxVisibleOrders,
|
||||
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
|
||||
@@ -143,6 +144,7 @@ export async function joinWorkerSharingOrder(
|
||||
workOrderId: Number(workOrderId),
|
||||
workerId: Number(worker.id),
|
||||
quantity,
|
||||
depositFreeAmount: permissions.depositFreeAmount,
|
||||
maxActiveOrders: permissions.maxActiveOrders,
|
||||
hallCandidateLimit: hallConfig.maxVisibleOrders,
|
||||
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
|
||||
|
||||
@@ -8,8 +8,19 @@ import {
|
||||
|
||||
export function resolveWorkerPermissions(worker: WorkerUserRow) {
|
||||
const permission = safeParseJson(worker.level_permission_json)
|
||||
const levelDepositFreeAmount = normalizeInteger(permission.depositFreeAmount, 0)
|
||||
const personalDepositFreeAmount = Number(worker.personal_deposit_free_amount)
|
||||
const hasPersonalDepositFreeAmount =
|
||||
worker.personal_deposit_free_amount !== null &&
|
||||
worker.personal_deposit_free_amount !== undefined &&
|
||||
Number.isInteger(personalDepositFreeAmount) &&
|
||||
personalDepositFreeAmount >= 0
|
||||
return {
|
||||
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
|
||||
// 个人额度是在等级额度基础上的追加额度,两者叠加生效。
|
||||
depositFreeAmount: levelDepositFreeAmount +
|
||||
(hasPersonalDepositFreeAmount ? personalDepositFreeAmount : 0),
|
||||
levelDepositFreeAmount,
|
||||
personalDepositFreeAmount: hasPersonalDepositFreeAmount ? personalDepositFreeAmount : null,
|
||||
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
|
||||
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
|
||||
autoAcceptWithoutEvidence: normalizeBoolean(permission.autoAcceptWithoutEvidence, false),
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import {
|
||||
buildWorkOrderWhere,
|
||||
resolveWorkOrderShareCancellationStatus,
|
||||
resolveWorkOrderShareDepositAmount,
|
||||
resolveWorkOrderSharingCancellationPlan,
|
||||
resolveWorkOrderShareJoinQuantity,
|
||||
resolveOpenSharingSyncReward,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
maskWorkerLeaderboardDisplayName,
|
||||
resolveCollectSubmitTargetWorkOrder,
|
||||
resolveAdminMaterialRewardAmount,
|
||||
resolveFreezeDepositAmount,
|
||||
resolveAdminMockWorkOrderInput,
|
||||
resolveMatchingProductRuleDecision,
|
||||
resolveWorkerPermissions,
|
||||
@@ -698,6 +700,43 @@ test('等级免审核后补图权限默认关闭,仅显式开启时生效', ()
|
||||
)
|
||||
})
|
||||
|
||||
test('个人免押额度与等级免押额度叠加', () => {
|
||||
const worker = buildWorkerUserRow({
|
||||
level_permission_json: { depositFreeAmount: 5_000 },
|
||||
personal_deposit_free_amount: 12_000,
|
||||
})
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
assert.equal(permissions.depositFreeAmount, 17_000)
|
||||
assert.equal(permissions.levelDepositFreeAmount, 5_000)
|
||||
assert.equal(permissions.personalDepositFreeAmount, 12_000)
|
||||
assert.equal(
|
||||
resolveFreezeDepositAmount(buildWorkOrderRow({ required_deposit_amount: 20_000 }), permissions),
|
||||
3_000,
|
||||
)
|
||||
})
|
||||
|
||||
test('拼单按打手累计份额扣除个人免押额度', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderShareDepositAmount({
|
||||
requiredDepositAmount: 10_000,
|
||||
totalQuantity: 10,
|
||||
nextQuantity: 5,
|
||||
depositFreeAmount: 3_000,
|
||||
}),
|
||||
2_000,
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderShareDepositAmount({
|
||||
requiredDepositAmount: 10_000,
|
||||
totalQuantity: 10,
|
||||
nextQuantity: 5,
|
||||
depositFreeAmount: 8_000,
|
||||
currentFrozenDeposit: 1_500,
|
||||
}),
|
||||
1_500,
|
||||
)
|
||||
})
|
||||
|
||||
test('后台整单指派给 VIP5 时不因母工单保留拼单配置而失去免审核资格', () => {
|
||||
const vip5Worker = buildWorkerUserRow({
|
||||
level_key: 'vip5',
|
||||
|
||||
Reference in New Issue
Block a user