feat:拼单订单新增一个取消部分的按钮功能,点击之后,打手进行中和待接单的都会取消订单
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
resolveSharingPartCancelPlan,
|
||||
resolveSharingPartCancelRefund,
|
||||
} from './work-order-sharing-part-cancel.js'
|
||||
|
||||
test('取消部分:保留已验收份数,取消进行中 + 待接单份数', () => {
|
||||
const plan = resolveSharingPartCancelPlan({
|
||||
workOrderStatus: 'open',
|
||||
totalQuantity: 10,
|
||||
acceptedQuantity: 8,
|
||||
})
|
||||
assert.deepEqual(plan, { nextTotalQuantity: 8, cancelledQuantity: 2, failureReason: null })
|
||||
})
|
||||
|
||||
test('取消部分:一份都没验收时整单取消(总量归零)', () => {
|
||||
const plan = resolveSharingPartCancelPlan({
|
||||
workOrderStatus: 'pending_acceptance',
|
||||
totalQuantity: 10,
|
||||
acceptedQuantity: 0,
|
||||
})
|
||||
assert.deepEqual(plan, { nextTotalQuantity: 0, cancelledQuantity: 10, failureReason: null })
|
||||
})
|
||||
|
||||
test('取消部分:全部份额已验收时没有可取消份数', () => {
|
||||
const plan = resolveSharingPartCancelPlan({
|
||||
workOrderStatus: 'accepted',
|
||||
totalQuantity: 8,
|
||||
acceptedQuantity: 8,
|
||||
})
|
||||
// accepted 不在允许状态里,优先报状态非法
|
||||
assert.equal(plan.failureReason, 'status_invalid')
|
||||
|
||||
const openPlan = resolveSharingPartCancelPlan({
|
||||
workOrderStatus: 'open',
|
||||
totalQuantity: 8,
|
||||
acceptedQuantity: 8,
|
||||
})
|
||||
assert.deepEqual(openPlan, {
|
||||
nextTotalQuantity: 8,
|
||||
cancelledQuantity: 0,
|
||||
failureReason: 'nothing_to_cancel',
|
||||
})
|
||||
})
|
||||
|
||||
test('取消部分:问题单允许操作,已取消/已验收单不允许', () => {
|
||||
assert.equal(
|
||||
resolveSharingPartCancelPlan({
|
||||
workOrderStatus: 'problem',
|
||||
totalQuantity: 3,
|
||||
acceptedQuantity: 1,
|
||||
}).failureReason,
|
||||
null,
|
||||
)
|
||||
for (const status of ['cancelled', 'accepted', 'unassigned']) {
|
||||
assert.equal(
|
||||
resolveSharingPartCancelPlan({
|
||||
workOrderStatus: status,
|
||||
totalQuantity: 3,
|
||||
acceptedQuantity: 0,
|
||||
}).failureReason,
|
||||
'status_invalid',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('取消部分:已验收份数异常时按总量截断', () => {
|
||||
const plan = resolveSharingPartCancelPlan({
|
||||
workOrderStatus: 'open',
|
||||
totalQuantity: 5,
|
||||
acceptedQuantity: 9,
|
||||
})
|
||||
assert.deepEqual(plan, {
|
||||
nextTotalQuantity: 5,
|
||||
cancelledQuantity: 0,
|
||||
failureReason: 'nothing_to_cancel',
|
||||
})
|
||||
})
|
||||
|
||||
test('取消部分退款:按实付金额比例计算并四舍五入到分', () => {
|
||||
assert.equal(
|
||||
resolveSharingPartCancelRefund({
|
||||
paidAmountFen: 20000,
|
||||
totalQuantity: 10,
|
||||
cancelledQuantity: 9,
|
||||
}),
|
||||
18000,
|
||||
)
|
||||
assert.equal(
|
||||
resolveSharingPartCancelRefund({ paidAmountFen: 999, totalQuantity: 3, cancelledQuantity: 1 }),
|
||||
333,
|
||||
)
|
||||
assert.equal(
|
||||
resolveSharingPartCancelRefund({ paidAmountFen: 0, totalQuantity: 10, cancelledQuantity: 10 }),
|
||||
0,
|
||||
)
|
||||
// 取消份数超过总量时按总量截断
|
||||
assert.equal(
|
||||
resolveSharingPartCancelRefund({ paidAmountFen: 5000, totalQuantity: 5, cancelledQuantity: 8 }),
|
||||
5000,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 拼单「取消部分」口径(已确认):
|
||||
* - 只保留已验收份额;打手进行中(joined / submitted)与待接单的剩余份数一起取消;
|
||||
* - 取消的份额退还押金,已验收份额的报酬 / 押金 / 验收资料一律不动;
|
||||
* - 取消后剩下的份额全部是已验收,所以母单直接收尾为已验收;一份都没验收则整单取消。
|
||||
*/
|
||||
|
||||
/** 允许执行取消部分的母单状态:问题单也允许,问题标记随份额一起取消。 */
|
||||
export const SHARING_PART_CANCEL_STATUSES = [
|
||||
'open',
|
||||
'in_progress',
|
||||
'pending_acceptance',
|
||||
'problem',
|
||||
] as const
|
||||
|
||||
export function resolveSharingPartCancelPlan(input: {
|
||||
workOrderStatus: string
|
||||
/** 母单当前总份数 */
|
||||
totalQuantity: number
|
||||
/** 已验收份额的份数合计 */
|
||||
acceptedQuantity: number
|
||||
}): {
|
||||
/** 取消后保留的总份数(= 已验收份数) */
|
||||
nextTotalQuantity: number
|
||||
/** 本次要取消的份数(进行中份额 + 待接单剩余) */
|
||||
cancelledQuantity: number
|
||||
failureReason: 'status_invalid' | 'nothing_to_cancel' | null
|
||||
} {
|
||||
const status = String(input.workOrderStatus || '').trim()
|
||||
if (!SHARING_PART_CANCEL_STATUSES.includes(status as never)) {
|
||||
return { nextTotalQuantity: 0, cancelledQuantity: 0, failureReason: 'status_invalid' }
|
||||
}
|
||||
const total = Math.max(0, Math.floor(Number(input.totalQuantity) || 0))
|
||||
const accepted = Math.min(total, Math.max(0, Math.floor(Number(input.acceptedQuantity) || 0)))
|
||||
const cancelled = total - accepted
|
||||
if (cancelled <= 0) {
|
||||
return { nextTotalQuantity: accepted, cancelledQuantity: 0, failureReason: 'nothing_to_cancel' }
|
||||
}
|
||||
return { nextTotalQuantity: accepted, cancelledQuantity: cancelled, failureReason: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 建议退款金额:按订单实付金额 × 取消份数 / 原总量,四舍五入到分。
|
||||
* 只是默认值,后台可以在弹窗里手工改。
|
||||
*/
|
||||
export function resolveSharingPartCancelRefund(input: {
|
||||
paidAmountFen: number
|
||||
totalQuantity: number
|
||||
cancelledQuantity: number
|
||||
}): number {
|
||||
const paid = Math.max(0, Math.round(Number(input.paidAmountFen) || 0))
|
||||
const total = Math.max(0, Math.floor(Number(input.totalQuantity) || 0))
|
||||
const cancelled = Math.max(0, Math.floor(Number(input.cancelledQuantity) || 0))
|
||||
if (paid <= 0 || total <= 0 || cancelled <= 0) return 0
|
||||
return Math.round((paid * Math.min(cancelled, total)) / total)
|
||||
}
|
||||
@@ -396,6 +396,8 @@ export type WorkOrderRow = {
|
||||
deposit_threshold_amount: number
|
||||
assigned_worker_id: number | null
|
||||
last_assigned_worker_id: number | null
|
||||
/** 来源订单实付金额(分);取消部分时用于按比例计算退款。 */
|
||||
source_order_amount_fen: number
|
||||
material_json: string | Record<string, unknown>
|
||||
requirement_json: string | Record<string, unknown>
|
||||
acceptance_json: string | Record<string, unknown>
|
||||
@@ -429,6 +431,8 @@ export type WorkOrderRow = {
|
||||
/** 拼单聚合字段,由工单查询统一计算。 */
|
||||
active_share_quantity?: number
|
||||
pending_share_count?: number
|
||||
/** 已验收份额的份数合计,用于「取消部分」判断还剩多少未验收。 */
|
||||
accepted_share_quantity?: number
|
||||
category_name?: string
|
||||
parent_category_id?: number | null
|
||||
parent_category_name?: string
|
||||
@@ -539,6 +543,8 @@ export type WorkOrderCapabilities = {
|
||||
canPublish: boolean
|
||||
canCancel: boolean
|
||||
canCancelSharing: boolean
|
||||
/** 取消部分:保留已验收份额,取消进行中份额与待接单剩余份数。 */
|
||||
canCancelSharingPart: boolean
|
||||
canResolveProblem: boolean
|
||||
canDeduct: boolean
|
||||
canConfirmFriend: boolean
|
||||
|
||||
@@ -20,6 +20,7 @@ export const WORK_ORDER_SELECT = `
|
||||
wo.*,
|
||||
COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity,
|
||||
COALESCE(share_summary.pending_share_count, 0)::int AS pending_share_count,
|
||||
COALESCE(share_summary.accepted_share_quantity, 0)::int AS accepted_share_quantity,
|
||||
wc.name AS category_name,
|
||||
parent_wc.id AS parent_category_id,
|
||||
parent_wc.name AS parent_category_name,
|
||||
@@ -65,7 +66,8 @@ export const WORK_ORDER_SELECT = `
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity,
|
||||
COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count
|
||||
COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count,
|
||||
SUM(wos.quantity) FILTER (WHERE wos.status = 'accepted') AS accepted_share_quantity
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = wo.id
|
||||
) share_summary ON TRUE
|
||||
|
||||
@@ -29,5 +29,6 @@ export {
|
||||
acceptWorkOrderShareAndSettle,
|
||||
cancelWorkOrderShare,
|
||||
cancelWorkOrderSharing,
|
||||
cancelWorkOrderSharingPart,
|
||||
settleOverdueWorkOrderShare,
|
||||
} from './work-order-settlement-repo.js'
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import {
|
||||
resolveSharingPartCancelPlan,
|
||||
resolveSharingPartCancelRefund,
|
||||
} from '../../domain/work-order-sharing-part-cancel.js'
|
||||
import { offsetWorkerAfterSalesDebtsWithClient } from './after-sales-repo.js'
|
||||
import {
|
||||
ensureWorkerWalletWithClient,
|
||||
@@ -22,6 +28,62 @@ import {
|
||||
settleConsignmentSupplierPayoutByWorkOrderWithClient,
|
||||
} from './consignment-deposit-repo.js'
|
||||
|
||||
/**
|
||||
* 拼单母单收尾:标记已验收,并补寄卖的发布押金释放与供货结算。
|
||||
*
|
||||
* 「最后一个份额验收」与「取消部分」都会把母单推到已验收,两条路径必须同口径,
|
||||
* 否则发布押金挂死、供货卖家拿不到货款。
|
||||
*/
|
||||
async function finalizeSharingOrderAcceptedWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
workOrder: WorkOrderRow
|
||||
workOrderId: number
|
||||
now: string
|
||||
unfreezeDays: number
|
||||
rewardUnfreezeEnabled: boolean
|
||||
},
|
||||
) {
|
||||
await client.query(
|
||||
`UPDATE work_orders SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
|
||||
[input.now, input.workOrderId],
|
||||
)
|
||||
|
||||
const shareMaterial =
|
||||
typeof input.workOrder.material_json === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(input.workOrder.material_json) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
: input.workOrder.material_json && typeof input.workOrder.material_json === 'object'
|
||||
? (input.workOrder.material_json as Record<string, unknown>)
|
||||
: {}
|
||||
const shareConsignment =
|
||||
shareMaterial.consignment && typeof shareMaterial.consignment === 'object'
|
||||
? (shareMaterial.consignment as Record<string, unknown>)
|
||||
: null
|
||||
const shareSourceSubmissionId = shareConsignment?.sourceSubmissionId
|
||||
if (shareSourceSubmissionId) {
|
||||
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId: String(shareSourceSubmissionId),
|
||||
note: `寄卖拼单份额全部验收完成释放发布押金 #${input.workOrderId}`,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
// 拼单母单没有单一接单人(份额打手已各自按 share_reward 结算),
|
||||
// 传 0 让供货货款必走本渠道,不会与份额报酬重复。
|
||||
await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
assignedWorkerId: 0,
|
||||
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
|
||||
export async function acceptWorkOrderAndSettle(input: {
|
||||
workOrderId: number
|
||||
unfreezeDays: number
|
||||
@@ -645,45 +707,13 @@ export async function acceptWorkOrderShareAndSettle(input: {
|
||||
const allAccepted = Number(progress?.unfinished_count || 0) === 0
|
||||
const nextOrderStatus = filled && allAccepted ? 'accepted' : workOrder.status
|
||||
if (nextOrderStatus === 'accepted') {
|
||||
await client.query(
|
||||
`UPDATE work_orders SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
|
||||
[input.now, input.workOrderId],
|
||||
)
|
||||
|
||||
// 最后一个份额把母单转成已验收时,必须与统一验收(acceptWorkOrderAndSettle)同口径补寄卖钩子,
|
||||
// 否则这条路径下发布押金永久挂死、供货卖家拿不到货款。
|
||||
const shareMaterial =
|
||||
typeof workOrder.material_json === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(workOrder.material_json) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
: workOrder.material_json && typeof workOrder.material_json === 'object'
|
||||
? (workOrder.material_json as Record<string, unknown>)
|
||||
: {}
|
||||
const shareConsignment =
|
||||
shareMaterial.consignment && typeof shareMaterial.consignment === 'object'
|
||||
? (shareMaterial.consignment as Record<string, unknown>)
|
||||
: null
|
||||
const shareSourceSubmissionId = shareConsignment?.sourceSubmissionId
|
||||
if (shareSourceSubmissionId) {
|
||||
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId: String(shareSourceSubmissionId),
|
||||
note: `寄卖拼单份额全部验收完成释放发布押金 #${input.workOrderId}`,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
// 拼单母单没有单一接单人(份额打手已在上面各自按 share_reward 结算),
|
||||
// 传 0 让供货货款必走本渠道,不会与份额报酬重复。
|
||||
await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, {
|
||||
// 最后一个份额把母单转成已验收,与统一验收(acceptWorkOrderAndSettle)同口径补寄卖钩子。
|
||||
await finalizeSharingOrderAcceptedWithClient(client, {
|
||||
workOrder,
|
||||
workOrderId: input.workOrderId,
|
||||
assignedWorkerId: 0,
|
||||
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
|
||||
})
|
||||
}
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
@@ -1226,3 +1256,235 @@ export async function cancelWorkOrderSharing(input: {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼单「取消部分」:只保留已验收份额,打手进行中(joined / submitted)与待接单的剩余份数一起取消。
|
||||
*
|
||||
* 口径(已确认):
|
||||
* - 取消的份额退还该份额冻结的押金(`min(份额押金, 钱包冻结额)`);已验收份额的报酬 / 押金 / 验收资料不动;
|
||||
* - 取消后总量收敛为已验收份数;一份都没验收则整单取消;
|
||||
* - 剩余份额全部已验收时,母单直接收尾为已验收(含寄卖发布押金释放与供货结算);
|
||||
* - 退款金额只登记(系统没有退款通道),默认按实付比例,后台可改。
|
||||
*/
|
||||
export async function cancelWorkOrderSharingPart(input: {
|
||||
workOrderId: number
|
||||
/** 退款金额(分);null / undefined 时按实付金额比例计算 */
|
||||
refundAmountFen?: number | null
|
||||
reason: string
|
||||
now: string
|
||||
actorName?: string
|
||||
unfreezeDays: number
|
||||
rewardUnfreezeEnabled: boolean
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
cancelledShareIds: number[]
|
||||
cancelledWorkerIds: number[]
|
||||
cancelledQuantity: number
|
||||
releasedDepositAmount: number
|
||||
refundAmount: number
|
||||
failureReason: 'work_order_not_cancellable' | 'status_invalid' | 'nothing_to_cancel' | null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const orderResult = await client.query<WorkOrderRow>(
|
||||
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = orderResult.rows[0] || null
|
||||
if (!workOrder || workOrder.sharing_enabled !== true) {
|
||||
return {
|
||||
order: workOrder,
|
||||
cancelledShareIds: [],
|
||||
cancelledWorkerIds: [],
|
||||
cancelledQuantity: 0,
|
||||
releasedDepositAmount: 0,
|
||||
refundAmount: 0,
|
||||
failureReason: 'work_order_not_cancellable' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const sharesResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares
|
||||
WHERE work_order_id = $1 AND status != 'cancelled'
|
||||
ORDER BY id ASC
|
||||
FOR UPDATE`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const shares = sharesResult.rows
|
||||
const totalQuantity = Math.max(1, Math.floor(Number(workOrder.sharing_total_quantity) || 1))
|
||||
const acceptedQuantity = shares
|
||||
.filter((share) => share.status === 'accepted')
|
||||
.reduce((sum, share) => sum + Math.max(0, Number(share.quantity || 0)), 0)
|
||||
const plan = resolveSharingPartCancelPlan({
|
||||
workOrderStatus: String(workOrder.status || ''),
|
||||
totalQuantity,
|
||||
acceptedQuantity,
|
||||
})
|
||||
if (plan.failureReason) {
|
||||
return {
|
||||
order: workOrder,
|
||||
cancelledShareIds: [],
|
||||
cancelledWorkerIds: [],
|
||||
cancelledQuantity: 0,
|
||||
releasedDepositAmount: 0,
|
||||
refundAmount: 0,
|
||||
failureReason: plan.failureReason,
|
||||
}
|
||||
}
|
||||
|
||||
const cancellableShares = shares.filter((share) =>
|
||||
['joined', 'submitted'].includes(String(share.status || '')),
|
||||
)
|
||||
let releasedDepositAmount = 0
|
||||
for (const share of cancellableShares) {
|
||||
const workerId = Number(share.worker_id || 0)
|
||||
if (workerId <= 0) continue
|
||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, workerId)
|
||||
const releaseAmount = Math.min(
|
||||
Number(share.share_deposit || 0),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
if (releaseAmount <= 0) continue
|
||||
const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
|
||||
await client.query(
|
||||
`UPDATE worker_wallets
|
||||
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
|
||||
WHERE worker_id = $4`,
|
||||
[nextAvailable, nextFrozen, input.now, workerId],
|
||||
)
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '取消部分拼单退还押金', $6::jsonb, $7)`,
|
||||
[
|
||||
workerId,
|
||||
releaseAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({
|
||||
workOrderId: input.workOrderId,
|
||||
shareId: Number(share.id),
|
||||
quantity: Number(share.quantity || 0),
|
||||
reason: String(input.reason || '').trim(),
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
releasedDepositAmount += releaseAmount
|
||||
}
|
||||
|
||||
const cancelledShareIds = cancellableShares.map((share) => Number(share.id))
|
||||
const cancelledWorkerIds = [
|
||||
...new Set(cancellableShares.map((share) => Number(share.worker_id || 0)).filter(Boolean)),
|
||||
]
|
||||
if (cancelledShareIds.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = 'cancelled',
|
||||
cancelled_reason = 'admin_partial',
|
||||
cancel_note = $3,
|
||||
cancel_quantity = COALESCE(quantity, 0),
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
accepted_at = NULL,
|
||||
problem_note = '',
|
||||
problem_marked_at = NULL,
|
||||
problem_marked_by = '',
|
||||
updated_at = $1
|
||||
WHERE id = ANY($2::bigint[])`,
|
||||
[input.now, cancelledShareIds, String(input.reason || '').trim()],
|
||||
)
|
||||
}
|
||||
|
||||
const refundAmount =
|
||||
input.refundAmountFen === undefined || input.refundAmountFen === null
|
||||
? resolveSharingPartCancelRefund({
|
||||
paidAmountFen: Number(workOrder.source_order_amount_fen || 0),
|
||||
totalQuantity,
|
||||
cancelledQuantity: plan.cancelledQuantity,
|
||||
})
|
||||
: Math.max(0, Math.round(Number(input.refundAmountFen) || 0))
|
||||
|
||||
if (plan.nextTotalQuantity <= 0) {
|
||||
await client.query(
|
||||
`UPDATE work_orders
|
||||
SET status = 'cancelled', assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL,
|
||||
${CLEAR_DELIVERY_RESIDUE_SQL},
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
published_at = NULL, pinned_at = NULL, updated_at = $1
|
||||
WHERE id = $2`,
|
||||
[input.now, input.workOrderId],
|
||||
)
|
||||
} else {
|
||||
const unitReward = Math.max(0, Number(workOrder.sharing_unit_reward || 0))
|
||||
const nextRewardAmount = Math.max(
|
||||
0,
|
||||
Number(workOrder.reward_amount || 0) - plan.cancelledQuantity * unitReward,
|
||||
)
|
||||
const nextRequiredDeposit =
|
||||
totalQuantity > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(Number(workOrder.required_deposit_amount || 0) * plan.nextTotalQuantity) /
|
||||
totalQuantity,
|
||||
),
|
||||
)
|
||||
: 0
|
||||
await client.query(
|
||||
`UPDATE work_orders
|
||||
SET sharing_total_quantity = $1, reward_amount = $2, required_deposit_amount = $3, updated_at = $4
|
||||
WHERE id = $5`,
|
||||
[
|
||||
plan.nextTotalQuantity,
|
||||
nextRewardAmount,
|
||||
nextRequiredDeposit,
|
||||
input.now,
|
||||
input.workOrderId,
|
||||
],
|
||||
)
|
||||
await finalizeSharingOrderAcceptedWithClient(client, {
|
||||
workOrder,
|
||||
workOrderId: input.workOrderId,
|
||||
now: input.now,
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'sharing_part_cancelled',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: plan.nextTotalQuantity <= 0 ? 'cancelled' : 'accepted',
|
||||
payloadJson: JSON.stringify({
|
||||
cancelledShareIds,
|
||||
cancelledShareCount: cancelledShareIds.length,
|
||||
cancelledQuantity: plan.cancelledQuantity,
|
||||
acceptedQuantity,
|
||||
totalQuantityBefore: totalQuantity,
|
||||
totalQuantityAfter: plan.nextTotalQuantity,
|
||||
releasedDepositAmount,
|
||||
refundAmount,
|
||||
reason: String(input.reason || '').trim(),
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
cancelledShareIds,
|
||||
cancelledWorkerIds,
|
||||
cancelledQuantity: plan.cancelledQuantity,
|
||||
releasedDepositAmount,
|
||||
refundAmount,
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
acceptAdminWorkOrderShare,
|
||||
cancelAdminWorkOrderShare,
|
||||
cancelAdminWorkOrderSharing,
|
||||
cancelAdminWorkOrderSharingPart,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
markAdminWorkOrderProblem,
|
||||
markAdminWorkOrderShareProblem,
|
||||
@@ -139,6 +140,31 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/sharing/cancel-part',
|
||||
requireAdminPermission('worker_order.cancel_share'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
cancelAdminWorkOrderSharingPart(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
req.adminSession?.permissions || [],
|
||||
),
|
||||
{
|
||||
successMessage: '已取消未验收部分',
|
||||
errorMessage: '取消部分失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/sharing/cancel-part]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_sharing_part_cancelled',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/shares/:shareId/cancel',
|
||||
requireAdminPermission('worker_order.cancel_share'),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
acceptWorkOrderShareAndSettle,
|
||||
cancelWorkOrderShare,
|
||||
cancelWorkOrderSharing,
|
||||
cancelWorkOrderSharingPart,
|
||||
countWorkOrderPendingSharingSubmissions,
|
||||
reduceWorkOrderShareQuantity,
|
||||
updateWorkOrder,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
import {
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderShare,
|
||||
normalizeAmountFen,
|
||||
normalizeOptionalId,
|
||||
resolveWorkOrderOperationalStatus,
|
||||
} from './mappers.js'
|
||||
@@ -329,6 +331,95 @@ export async function cancelAdminWorkOrderSharing(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼单「取消部分」:保留已验收份额,取消打手进行中(joined / submitted)与待接单的剩余份数,
|
||||
* 取消的份额退还押金;一份都没验收时整单取消。
|
||||
*/
|
||||
export async function cancelAdminWorkOrderSharingPart(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
actorName = '',
|
||||
actorPermissions: string[] = [],
|
||||
) {
|
||||
if (!actorPermissions.includes('worker_order.cancel_share')) {
|
||||
throw createHttpError('当前账号没有取消部分拼单的权限', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_permission_denied',
|
||||
})
|
||||
}
|
||||
const reason = String(payload.reason || '').trim()
|
||||
if (!reason) {
|
||||
throw createHttpError('取消部分时请填写原因', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_sharing_part_cancel_reason_required',
|
||||
})
|
||||
}
|
||||
const rawRefundAmount = payload.refundAmount
|
||||
let refundAmountFen: number | null = null
|
||||
if (
|
||||
rawRefundAmount !== undefined &&
|
||||
rawRefundAmount !== null &&
|
||||
String(rawRefundAmount).trim() !== ''
|
||||
) {
|
||||
refundAmountFen = normalizeAmountFen(rawRefundAmount, -1)
|
||||
if (refundAmountFen < 0) {
|
||||
throw createHttpError('退款金额不能为负数', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_sharing_part_cancel_refund_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
const financeConfig = getWorkerFinanceConfig()
|
||||
const result = await cancelWorkOrderSharingPart({
|
||||
workOrderId: Number(workOrderId),
|
||||
refundAmountFen,
|
||||
reason,
|
||||
now: nowIso(),
|
||||
actorName,
|
||||
unfreezeDays: financeConfig.depositUnfreezeDays,
|
||||
rewardUnfreezeEnabled: financeConfig.rewardUnfreezeEnabled,
|
||||
})
|
||||
if (result.failureReason === 'work_order_not_cancellable') {
|
||||
throw createHttpError('当前订单不是可取消部分的拼单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_part_cancel_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'status_invalid') {
|
||||
throw createHttpError('当前订单状态不能取消部分', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_part_cancel_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'nothing_to_cancel') {
|
||||
throw createHttpError('没有可取消的未验收份数', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_part_cancel_nothing_to_cancel',
|
||||
})
|
||||
}
|
||||
if (!result.order) {
|
||||
throw createHttpError('取消部分失败,订单状态可能已变化', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_part_cancel_conflict',
|
||||
})
|
||||
}
|
||||
const activeWorkerIds = await resolveWorkOrderRealtimeWorkerIds(result.order)
|
||||
const workerIds = [...new Set([...activeWorkerIds, ...result.cancelledWorkerIds])]
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrderId),
|
||||
workerIds,
|
||||
hallChanged: true,
|
||||
})
|
||||
for (const workerId of result.cancelledWorkerIds) publishWorkerWalletRealtimeChange(workerId)
|
||||
return {
|
||||
order: mapWorkOrderAdmin(result.order),
|
||||
cancelledShareCount: result.cancelledShareIds.length,
|
||||
cancelledQuantity: result.cancelledQuantity,
|
||||
releasedDepositAmount: result.releasedDepositAmount,
|
||||
refundAmount: result.refundAmount,
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') {
|
||||
const workOrderIds = [
|
||||
...new Set(
|
||||
|
||||
@@ -54,6 +54,7 @@ export {
|
||||
acceptAdminWorkOrders,
|
||||
cancelAdminWorkOrderShare,
|
||||
cancelAdminWorkOrderSharing,
|
||||
cancelAdminWorkOrderSharingPart,
|
||||
reduceAdminWorkOrderShareQuantity,
|
||||
} from './admin-work-order-acceptance-service.js'
|
||||
export * from './worker-product-match-config-service.js'
|
||||
|
||||
@@ -248,6 +248,7 @@ export function mapWorkOrderAdmin(
|
||||
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
|
||||
const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0))
|
||||
const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
|
||||
const acceptedShareQuantity = Math.max(0, Number(workOrder.accepted_share_quantity || 0))
|
||||
const hasActiveShares = activeShareQuantity > 0
|
||||
const sharingFilled =
|
||||
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
|
||||
@@ -306,6 +307,10 @@ export function mapWorkOrderAdmin(
|
||||
workOrder.sharing_enabled === true &&
|
||||
hasActiveShares &&
|
||||
['open', 'pending_acceptance', 'in_progress'].includes(workOrder.status),
|
||||
canCancelSharingPart:
|
||||
workOrder.sharing_enabled === true &&
|
||||
sharingTotalQuantity > acceptedShareQuantity &&
|
||||
['open', 'pending_acceptance', 'in_progress', 'problem'].includes(workOrder.status),
|
||||
canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
||||
canConfirmFriend:
|
||||
@@ -369,11 +374,15 @@ export function mapWorkOrderAdmin(
|
||||
joinedQuantity: activeShareQuantity,
|
||||
pendingSubmissionCount: pendingShareCount,
|
||||
remainingQuantity: Math.max(0, sharingTotalQuantity - activeShareQuantity),
|
||||
acceptedQuantity: acceptedShareQuantity,
|
||||
cancellableQuantity: Math.max(0, sharingTotalQuantity - acceptedShareQuantity),
|
||||
filled: sharingFilled,
|
||||
},
|
||||
capabilities,
|
||||
rewardAmount: Number(workOrder.reward_amount || 0),
|
||||
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||
/** 来源订单实付金额(分):取消部分时用于预填退款金额。 */
|
||||
sourceOrderAmount: Number(workOrder.source_order_amount_fen || 0),
|
||||
sharing: {
|
||||
enabled: workOrder.sharing_enabled === true,
|
||||
totalQuantity: Number(workOrder.sharing_total_quantity || 1),
|
||||
|
||||
@@ -3501,3 +3501,49 @@ test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参
|
||||
'押金必须按该打手在本工单的累计份数与已冻结合计计算增量',
|
||||
)
|
||||
})
|
||||
|
||||
test('取消部分:只取消未验收份额并保留已验收,剩余份数收敛后收尾母单', () => {
|
||||
const repoSrc = fs.readFileSync(
|
||||
new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const block = repoSrc.slice(
|
||||
repoSrc.indexOf('export async function cancelWorkOrderSharingPart('),
|
||||
repoSrc.length,
|
||||
)
|
||||
assert.ok(block.length > 0, 'cancelWorkOrderSharingPart 必须存在')
|
||||
assert.ok(
|
||||
block.includes("['joined', 'submitted']"),
|
||||
'只允许取消进行中(joined / submitted)份额,已验收份额必须保留',
|
||||
)
|
||||
assert.ok(
|
||||
!block.includes("status = 'accepted'"),
|
||||
'母单转已验收必须走统一收尾函数,不得直接改状态',
|
||||
)
|
||||
assert.ok(
|
||||
block.includes('finalizeSharingOrderAcceptedWithClient('),
|
||||
'有已验收份额时必须走统一收尾(含寄卖结算)',
|
||||
)
|
||||
assert.ok(block.includes('deposit_release'), '取消的份额必须退还押金')
|
||||
assert.ok(block.includes('admin_partial'), '取消原因必须标记为 admin_partial')
|
||||
assert.ok(block.includes('problem_marked_at = NULL'), '问题标记要随被取消的份额一起清掉')
|
||||
assert.ok(block.includes('sharing_total_quantity = $1'), '取消后必须把母单总量收敛为已验收份数')
|
||||
assert.ok(
|
||||
block.includes('source_order_amount_fen') && block.includes('resolveSharingPartCancelRefund('),
|
||||
'退款默认值必须按实付金额比例计算',
|
||||
)
|
||||
|
||||
const routeSrc = fs.readFileSync(
|
||||
new URL('../../routes/admin/worker-platform/acceptance.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
routeSrc.includes("'/worker-platform/orders/:workOrderId/sharing/cancel-part'"),
|
||||
'取消部分接口必须存在',
|
||||
)
|
||||
const mapperSrc = fs.readFileSync(
|
||||
new URL('./work-order-display-mappers.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(mapperSrc.includes('canCancelSharingPart:'), '前端按钮依赖 canCancelSharingPart 能力位')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user