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
|
deposit_threshold_amount: number
|
||||||
assigned_worker_id: number | null
|
assigned_worker_id: number | null
|
||||||
last_assigned_worker_id: number | null
|
last_assigned_worker_id: number | null
|
||||||
|
/** 来源订单实付金额(分);取消部分时用于按比例计算退款。 */
|
||||||
|
source_order_amount_fen: number
|
||||||
material_json: string | Record<string, unknown>
|
material_json: string | Record<string, unknown>
|
||||||
requirement_json: string | Record<string, unknown>
|
requirement_json: string | Record<string, unknown>
|
||||||
acceptance_json: string | Record<string, unknown>
|
acceptance_json: string | Record<string, unknown>
|
||||||
@@ -429,6 +431,8 @@ export type WorkOrderRow = {
|
|||||||
/** 拼单聚合字段,由工单查询统一计算。 */
|
/** 拼单聚合字段,由工单查询统一计算。 */
|
||||||
active_share_quantity?: number
|
active_share_quantity?: number
|
||||||
pending_share_count?: number
|
pending_share_count?: number
|
||||||
|
/** 已验收份额的份数合计,用于「取消部分」判断还剩多少未验收。 */
|
||||||
|
accepted_share_quantity?: number
|
||||||
category_name?: string
|
category_name?: string
|
||||||
parent_category_id?: number | null
|
parent_category_id?: number | null
|
||||||
parent_category_name?: string
|
parent_category_name?: string
|
||||||
@@ -539,6 +543,8 @@ export type WorkOrderCapabilities = {
|
|||||||
canPublish: boolean
|
canPublish: boolean
|
||||||
canCancel: boolean
|
canCancel: boolean
|
||||||
canCancelSharing: boolean
|
canCancelSharing: boolean
|
||||||
|
/** 取消部分:保留已验收份额,取消进行中份额与待接单剩余份数。 */
|
||||||
|
canCancelSharingPart: boolean
|
||||||
canResolveProblem: boolean
|
canResolveProblem: boolean
|
||||||
canDeduct: boolean
|
canDeduct: boolean
|
||||||
canConfirmFriend: boolean
|
canConfirmFriend: boolean
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const WORK_ORDER_SELECT = `
|
|||||||
wo.*,
|
wo.*,
|
||||||
COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity,
|
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.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,
|
wc.name AS category_name,
|
||||||
parent_wc.id AS parent_category_id,
|
parent_wc.id AS parent_category_id,
|
||||||
parent_wc.name AS parent_category_name,
|
parent_wc.name AS parent_category_name,
|
||||||
@@ -65,7 +66,8 @@ export const WORK_ORDER_SELECT = `
|
|||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity,
|
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
|
FROM work_order_shares wos
|
||||||
WHERE wos.work_order_id = wo.id
|
WHERE wos.work_order_id = wo.id
|
||||||
) share_summary ON TRUE
|
) share_summary ON TRUE
|
||||||
|
|||||||
@@ -29,5 +29,6 @@ export {
|
|||||||
acceptWorkOrderShareAndSettle,
|
acceptWorkOrderShareAndSettle,
|
||||||
cancelWorkOrderShare,
|
cancelWorkOrderShare,
|
||||||
cancelWorkOrderSharing,
|
cancelWorkOrderSharing,
|
||||||
|
cancelWorkOrderSharingPart,
|
||||||
settleOverdueWorkOrderShare,
|
settleOverdueWorkOrderShare,
|
||||||
} from './work-order-settlement-repo.js'
|
} from './work-order-settlement-repo.js'
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import type { PoolClient } from 'pg'
|
||||||
|
|
||||||
import { withTransaction } from '../../db/client.js'
|
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 { offsetWorkerAfterSalesDebtsWithClient } from './after-sales-repo.js'
|
||||||
import {
|
import {
|
||||||
ensureWorkerWalletWithClient,
|
ensureWorkerWalletWithClient,
|
||||||
@@ -22,6 +28,62 @@ import {
|
|||||||
settleConsignmentSupplierPayoutByWorkOrderWithClient,
|
settleConsignmentSupplierPayoutByWorkOrderWithClient,
|
||||||
} from './consignment-deposit-repo.js'
|
} 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: {
|
export async function acceptWorkOrderAndSettle(input: {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
unfreezeDays: number
|
unfreezeDays: number
|
||||||
@@ -645,45 +707,13 @@ export async function acceptWorkOrderShareAndSettle(input: {
|
|||||||
const allAccepted = Number(progress?.unfinished_count || 0) === 0
|
const allAccepted = Number(progress?.unfinished_count || 0) === 0
|
||||||
const nextOrderStatus = filled && allAccepted ? 'accepted' : workOrder.status
|
const nextOrderStatus = filled && allAccepted ? 'accepted' : workOrder.status
|
||||||
if (nextOrderStatus === 'accepted') {
|
if (nextOrderStatus === 'accepted') {
|
||||||
await client.query(
|
// 最后一个份额把母单转成已验收,与统一验收(acceptWorkOrderAndSettle)同口径补寄卖钩子。
|
||||||
`UPDATE work_orders SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
|
await finalizeSharingOrderAcceptedWithClient(client, {
|
||||||
[input.now, input.workOrderId],
|
workOrder,
|
||||||
)
|
|
||||||
|
|
||||||
// 最后一个份额把母单转成已验收时,必须与统一验收(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, {
|
|
||||||
workOrderId: input.workOrderId,
|
workOrderId: input.workOrderId,
|
||||||
assignedWorkerId: 0,
|
|
||||||
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
|
|
||||||
unfreezeDays: input.unfreezeDays,
|
|
||||||
now: input.now,
|
now: input.now,
|
||||||
|
unfreezeDays: input.unfreezeDays,
|
||||||
|
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await createWorkOrderEventWithClient(client, {
|
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,
|
acceptAdminWorkOrderShare,
|
||||||
cancelAdminWorkOrderShare,
|
cancelAdminWorkOrderShare,
|
||||||
cancelAdminWorkOrderSharing,
|
cancelAdminWorkOrderSharing,
|
||||||
|
cancelAdminWorkOrderSharingPart,
|
||||||
deductAdminWorkOrderPendingDeposit,
|
deductAdminWorkOrderPendingDeposit,
|
||||||
markAdminWorkOrderProblem,
|
markAdminWorkOrderProblem,
|
||||||
markAdminWorkOrderShareProblem,
|
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(
|
router.post(
|
||||||
'/worker-platform/orders/:workOrderId/shares/:shareId/cancel',
|
'/worker-platform/orders/:workOrderId/shares/:shareId/cancel',
|
||||||
requireAdminPermission('worker_order.cancel_share'),
|
requireAdminPermission('worker_order.cancel_share'),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
acceptWorkOrderShareAndSettle,
|
acceptWorkOrderShareAndSettle,
|
||||||
cancelWorkOrderShare,
|
cancelWorkOrderShare,
|
||||||
cancelWorkOrderSharing,
|
cancelWorkOrderSharing,
|
||||||
|
cancelWorkOrderSharingPart,
|
||||||
countWorkOrderPendingSharingSubmissions,
|
countWorkOrderPendingSharingSubmissions,
|
||||||
reduceWorkOrderShareQuantity,
|
reduceWorkOrderShareQuantity,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
mapWorkOrderAdmin,
|
mapWorkOrderAdmin,
|
||||||
mapWorkOrderShare,
|
mapWorkOrderShare,
|
||||||
|
normalizeAmountFen,
|
||||||
normalizeOptionalId,
|
normalizeOptionalId,
|
||||||
resolveWorkOrderOperationalStatus,
|
resolveWorkOrderOperationalStatus,
|
||||||
} from './mappers.js'
|
} 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 = '') {
|
export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') {
|
||||||
const workOrderIds = [
|
const workOrderIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export {
|
|||||||
acceptAdminWorkOrders,
|
acceptAdminWorkOrders,
|
||||||
cancelAdminWorkOrderShare,
|
cancelAdminWorkOrderShare,
|
||||||
cancelAdminWorkOrderSharing,
|
cancelAdminWorkOrderSharing,
|
||||||
|
cancelAdminWorkOrderSharingPart,
|
||||||
reduceAdminWorkOrderShareQuantity,
|
reduceAdminWorkOrderShareQuantity,
|
||||||
} from './admin-work-order-acceptance-service.js'
|
} from './admin-work-order-acceptance-service.js'
|
||||||
export * from './worker-product-match-config-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 activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
|
||||||
const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0))
|
const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0))
|
||||||
const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
|
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 hasActiveShares = activeShareQuantity > 0
|
||||||
const sharingFilled =
|
const sharingFilled =
|
||||||
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
|
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
|
||||||
@@ -306,6 +307,10 @@ export function mapWorkOrderAdmin(
|
|||||||
workOrder.sharing_enabled === true &&
|
workOrder.sharing_enabled === true &&
|
||||||
hasActiveShares &&
|
hasActiveShares &&
|
||||||
['open', 'pending_acceptance', 'in_progress'].includes(workOrder.status),
|
['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,
|
canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||||
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
||||||
canConfirmFriend:
|
canConfirmFriend:
|
||||||
@@ -369,11 +374,15 @@ export function mapWorkOrderAdmin(
|
|||||||
joinedQuantity: activeShareQuantity,
|
joinedQuantity: activeShareQuantity,
|
||||||
pendingSubmissionCount: pendingShareCount,
|
pendingSubmissionCount: pendingShareCount,
|
||||||
remainingQuantity: Math.max(0, sharingTotalQuantity - activeShareQuantity),
|
remainingQuantity: Math.max(0, sharingTotalQuantity - activeShareQuantity),
|
||||||
|
acceptedQuantity: acceptedShareQuantity,
|
||||||
|
cancellableQuantity: Math.max(0, sharingTotalQuantity - acceptedShareQuantity),
|
||||||
filled: sharingFilled,
|
filled: sharingFilled,
|
||||||
},
|
},
|
||||||
capabilities,
|
capabilities,
|
||||||
rewardAmount: Number(workOrder.reward_amount || 0),
|
rewardAmount: Number(workOrder.reward_amount || 0),
|
||||||
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||||
|
/** 来源订单实付金额(分):取消部分时用于预填退款金额。 */
|
||||||
|
sourceOrderAmount: Number(workOrder.source_order_amount_fen || 0),
|
||||||
sharing: {
|
sharing: {
|
||||||
enabled: workOrder.sharing_enabled === true,
|
enabled: workOrder.sharing_enabled === true,
|
||||||
totalQuantity: Number(workOrder.sharing_total_quantity || 1),
|
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 能力位')
|
||||||
|
})
|
||||||
|
|||||||
@@ -440,6 +440,24 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
|||||||
color: 'red',
|
color: 'red',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case 'sharing_part_cancelled': {
|
||||||
|
const cancelledQuantity = readPayloadAmount(event, 'cancelledQuantity')
|
||||||
|
const totalQuantityAfter = readPayloadAmount(event, 'totalQuantityAfter')
|
||||||
|
const releasedDepositAmount = readPayloadAmount(event, 'releasedDepositAmount')
|
||||||
|
const refundAmount = readPayloadAmount(event, 'refundAmount')
|
||||||
|
const details = [
|
||||||
|
cancelledQuantity > 0 ? `取消 ${cancelledQuantity} 份未验收份数` : '',
|
||||||
|
totalQuantityAfter > 0 ? `保留 ${totalQuantityAfter} 份已验收` : '整单已取消',
|
||||||
|
releasedDepositAmount > 0 ? `退还押金 ${formatFenAmount(releasedDepositAmount)}` : '',
|
||||||
|
refundAmount > 0 ? `登记退款 ${formatFenAmount(refundAmount)}` : '',
|
||||||
|
note ? `原因:${note}` : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
return {
|
||||||
|
title: '取消拼单未验收部分',
|
||||||
|
description: details.length > 0 ? details.join(';') : undefined,
|
||||||
|
color: 'red',
|
||||||
|
}
|
||||||
|
}
|
||||||
case 'pending_deposit_deducted':
|
case 'pending_deposit_deducted':
|
||||||
return { title: '扣减了待解冻押金', color: 'default' }
|
return { title: '扣减了待解冻押金', color: 'default' }
|
||||||
case 'voucher_backfilled':
|
case 'voucher_backfilled':
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
cancelAdminWorkOrder,
|
cancelAdminWorkOrder,
|
||||||
cancelAdminWorkOrderShare,
|
cancelAdminWorkOrderShare,
|
||||||
cancelAdminWorkOrderSharing,
|
cancelAdminWorkOrderSharing,
|
||||||
|
cancelAdminWorkOrderSharingPart,
|
||||||
completeAdminWorkOrderGiftCooldown,
|
completeAdminWorkOrderGiftCooldown,
|
||||||
confirmAdminWorkOrderFriend,
|
confirmAdminWorkOrderFriend,
|
||||||
deductAdminWorkOrderPendingDeposit,
|
deductAdminWorkOrderPendingDeposit,
|
||||||
@@ -185,6 +186,11 @@ type CancelSharingFormValues = {
|
|||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CancelSharingPartFormValues = {
|
||||||
|
refundAmount?: number
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
|
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
|
||||||
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
|
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
|
||||||
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
|
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
|
||||||
@@ -311,6 +317,7 @@ export default function WorkOrdersPanel() {
|
|||||||
const [problemShare, setProblemShare] = useState<WorkOrderShare | null>(null)
|
const [problemShare, setProblemShare] = useState<WorkOrderShare | null>(null)
|
||||||
const [disposeShare, setDisposeShare] = useState<WorkOrderShare | null>(null)
|
const [disposeShare, setDisposeShare] = useState<WorkOrderShare | null>(null)
|
||||||
const [cancelSharingOrder, setCancelSharingOrder] = useState<WorkOrder | null>(null)
|
const [cancelSharingOrder, setCancelSharingOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [cancelSharingPartOrder, setCancelSharingPartOrder] = useState<WorkOrder | null>(null)
|
||||||
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
|
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
|
||||||
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
|
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
|
||||||
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
|
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
|
||||||
@@ -329,6 +336,7 @@ export default function WorkOrdersPanel() {
|
|||||||
note?: string
|
note?: string
|
||||||
}>()
|
}>()
|
||||||
const [cancelSharingForm] = Form.useForm<CancelSharingFormValues>()
|
const [cancelSharingForm] = Form.useForm<CancelSharingFormValues>()
|
||||||
|
const [cancelSharingPartForm] = Form.useForm<CancelSharingPartFormValues>()
|
||||||
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
|
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
|
||||||
const [editForm] = Form.useForm<{
|
const [editForm] = Form.useForm<{
|
||||||
productName?: string
|
productName?: string
|
||||||
@@ -551,6 +559,18 @@ export default function WorkOrdersPanel() {
|
|||||||
['joined', 'submitted'].includes(share.status),
|
['joined', 'submitted'].includes(share.status),
|
||||||
)
|
)
|
||||||
const hasAcceptedSharingShares = sharingShares.some((share) => share.status === 'accepted')
|
const hasAcceptedSharingShares = sharingShares.some((share) => share.status === 'accepted')
|
||||||
|
const cancelSharingPartTotalQuantity = Math.max(
|
||||||
|
1,
|
||||||
|
Number(cancelSharingPartOrder?.sharing?.totalQuantity || 1),
|
||||||
|
)
|
||||||
|
const cancelSharingPartAcceptedQuantity = Math.max(
|
||||||
|
0,
|
||||||
|
Number(cancelSharingPartOrder?.sharingProgress?.acceptedQuantity || 0),
|
||||||
|
)
|
||||||
|
const cancelSharingPartCancelledQuantity = Math.max(
|
||||||
|
0,
|
||||||
|
cancelSharingPartTotalQuantity - cancelSharingPartAcceptedQuantity,
|
||||||
|
)
|
||||||
const assignWorkersQuery = useQuery({
|
const assignWorkersQuery = useQuery({
|
||||||
queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword],
|
queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -821,6 +841,48 @@ export default function WorkOrdersPanel() {
|
|||||||
cancelSharingForm.resetFields()
|
cancelSharingForm.resetFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开「取消部分」:默认按实付金额比例预填退款金额。 */
|
||||||
|
function openCancelSharingPartModal(order: WorkOrder) {
|
||||||
|
const totalQuantity = Math.max(1, Number(order.sharing?.totalQuantity || 1))
|
||||||
|
const acceptedQuantity = Math.max(0, Number(order.sharingProgress?.acceptedQuantity || 0))
|
||||||
|
const cancelledQuantity = Math.max(0, totalQuantity - acceptedQuantity)
|
||||||
|
const paidAmountFen = Math.max(0, Number(order.sourceOrderAmount || 0))
|
||||||
|
setCancelSharingPartOrder(order)
|
||||||
|
cancelSharingPartForm.setFieldsValue({
|
||||||
|
refundAmount:
|
||||||
|
totalQuantity > 0
|
||||||
|
? Math.round((paidAmountFen * cancelledQuantity) / totalQuantity) / 100
|
||||||
|
: 0,
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCancelSharingPart(values: CancelSharingPartFormValues) {
|
||||||
|
if (!cancelSharingPartOrder) return
|
||||||
|
const workOrderId = cancelSharingPartOrder.workOrderId
|
||||||
|
const refundAmount = values.refundAmount
|
||||||
|
const succeeded = await runAction(
|
||||||
|
() =>
|
||||||
|
cancelAdminWorkOrderSharingPart(workOrderId, {
|
||||||
|
...(refundAmount === undefined || refundAmount === null
|
||||||
|
? {}
|
||||||
|
: { refundAmount: Number(refundAmount) }),
|
||||||
|
reason: String(values.reason || '').trim(),
|
||||||
|
}),
|
||||||
|
(result) =>
|
||||||
|
`已取消 ${result.data.cancelledQuantity} 份未验收份数,退还押金 ${formatMoney(
|
||||||
|
result.data.releasedDepositAmount,
|
||||||
|
)},登记退款 ${formatMoney(result.data.refundAmount)}`,
|
||||||
|
)
|
||||||
|
if (!succeeded) return
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-order-sharing', workOrderId],
|
||||||
|
})
|
||||||
|
setCancelSharingPartOrder(null)
|
||||||
|
setSharingOrder(null)
|
||||||
|
cancelSharingPartForm.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -3114,6 +3176,7 @@ export default function WorkOrdersPanel() {
|
|||||||
setCancelShare(null)
|
setCancelShare(null)
|
||||||
setReduceShare(null)
|
setReduceShare(null)
|
||||||
setCancelSharingOrder(null)
|
setCancelSharingOrder(null)
|
||||||
|
setCancelSharingPartOrder(null)
|
||||||
sharingForm.resetFields()
|
sharingForm.resetFields()
|
||||||
cancelShareForm.resetFields()
|
cancelShareForm.resetFields()
|
||||||
reduceShareForm.resetFields()
|
reduceShareForm.resetFields()
|
||||||
@@ -3201,6 +3264,26 @@ export default function WorkOrdersPanel() {
|
|||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Card size="small" title="拼单明细">
|
<Card size="small" title="拼单明细">
|
||||||
|
{sharingOrder.capabilities?.canCancelSharingPart ? (
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
size={8}
|
||||||
|
className="full-width"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message="取消部分会取消所有未验收份数(进行中 + 待接单)"
|
||||||
|
description="已验收份额保留;取消的份额退还押金。退款金额只登记,实际退款请在来源平台操作。"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Button danger onClick={() => openCancelSharingPartModal(sharingOrder)}>
|
||||||
|
取消部分
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
{sharingQuery.isLoading ? (
|
{sharingQuery.isLoading ? (
|
||||||
<Typography.Text type="secondary">加载中...</Typography.Text>
|
<Typography.Text type="secondary">加载中...</Typography.Text>
|
||||||
) : (sharingQuery.data?.data.shares || []).length === 0 ? (
|
) : (sharingQuery.data?.data.shares || []).length === 0 ? (
|
||||||
@@ -3782,6 +3865,60 @@ export default function WorkOrdersPanel() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`取消部分 · ${getDisplayOrderNo(cancelSharingPartOrder)}`}
|
||||||
|
open={Boolean(cancelSharingPartOrder)}
|
||||||
|
onCancel={() => {
|
||||||
|
setCancelSharingPartOrder(null)
|
||||||
|
cancelSharingPartForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => cancelSharingPartForm.submit()}
|
||||||
|
okText="确认取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{cancelSharingPartOrder ? (
|
||||||
|
<Form form={cancelSharingPartForm} layout="vertical" onFinish={submitCancelSharingPart}>
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message="只保留已验收份额,其余份数全部取消"
|
||||||
|
description={
|
||||||
|
cancelSharingPartAcceptedQuantity > 0
|
||||||
|
? '取消后母单总量收敛为已验收份数,并直接转为已验收;取消的份额会退还押金。'
|
||||||
|
: '本单没有已验收份额,提交后整张订单会被取消;取消的份额会退还押金。'
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Descriptions size="small" column={1} bordered style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="当前总量">
|
||||||
|
{cancelSharingPartTotalQuantity} 份
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="已验收(保留)">
|
||||||
|
{cancelSharingPartAcceptedQuantity} 份
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="将取消">
|
||||||
|
{cancelSharingPartCancelledQuantity} 份(进行中 + 待接单)
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Form.Item
|
||||||
|
label="退款金额"
|
||||||
|
name="refundAmount"
|
||||||
|
extra="默认按订单实付金额 × 取消份数 / 原总量计算,可手工修改;本系统只登记金额。"
|
||||||
|
>
|
||||||
|
<InputNumber min={0} precision={2} addonAfter="元" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="取消原因"
|
||||||
|
name="reason"
|
||||||
|
rules={[{ required: true, whitespace: true, message: '请填写取消原因' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={3} placeholder="如:剩余份数不再需要,按实付比例退款" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* 内部寄售库存匹配拦截弹窗 */}
|
{/* 内部寄售库存匹配拦截弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -942,6 +942,24 @@ export function cancelAdminWorkOrderSharing(
|
|||||||
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing/cancel`, payload)
|
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing/cancel`, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取消部分:保留已验收份额,取消进行中份额与待接单剩余份数,退还取消份额的押金。 */
|
||||||
|
export function cancelAdminWorkOrderSharingPart(
|
||||||
|
workOrderId: number,
|
||||||
|
payload: {
|
||||||
|
/** 退款金额(元);不传则后端按实付比例算 */
|
||||||
|
refundAmount?: number
|
||||||
|
reason: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return apiPost<{
|
||||||
|
order: WorkOrder
|
||||||
|
cancelledShareCount: number
|
||||||
|
cancelledQuantity: number
|
||||||
|
releasedDepositAmount: number
|
||||||
|
refundAmount: number
|
||||||
|
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing/cancel-part`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
export function acceptAdminWorkOrders(workOrderIds: number[]) {
|
export function acceptAdminWorkOrders(workOrderIds: number[]) {
|
||||||
return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', {
|
return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', {
|
||||||
workOrderIds,
|
workOrderIds,
|
||||||
|
|||||||
@@ -648,6 +648,8 @@ export type WorkOrder = {
|
|||||||
canPublish: boolean
|
canPublish: boolean
|
||||||
canCancel: boolean
|
canCancel: boolean
|
||||||
canCancelSharing?: boolean
|
canCancelSharing?: boolean
|
||||||
|
/** 取消部分:保留已验收份额,取消进行中份额与待接单剩余份数。 */
|
||||||
|
canCancelSharingPart?: boolean
|
||||||
canResolveProblem: boolean
|
canResolveProblem: boolean
|
||||||
canDeduct: boolean
|
canDeduct: boolean
|
||||||
canConfirmFriend?: boolean
|
canConfirmFriend?: boolean
|
||||||
@@ -655,6 +657,8 @@ export type WorkOrder = {
|
|||||||
canResetGiftFlow?: boolean
|
canResetGiftFlow?: boolean
|
||||||
}
|
}
|
||||||
rewardAmount: number
|
rewardAmount: number
|
||||||
|
/** 来源订单实付金额(分):取消部分时用于预填退款金额。 */
|
||||||
|
sourceOrderAmount?: number
|
||||||
/** 当前打手生效的免押额度,仅打手端订单数据返回。 */
|
/** 当前打手生效的免押额度,仅打手端订单数据返回。 */
|
||||||
depositFreeAmount?: number
|
depositFreeAmount?: number
|
||||||
requiredDepositAmount?: number
|
requiredDepositAmount?: number
|
||||||
@@ -668,6 +672,10 @@ export type WorkOrder = {
|
|||||||
joinedQuantity: number
|
joinedQuantity: number
|
||||||
pendingSubmissionCount: number
|
pendingSubmissionCount: number
|
||||||
remainingQuantity?: number
|
remainingQuantity?: number
|
||||||
|
/** 已验收份额的份数合计 */
|
||||||
|
acceptedQuantity?: number
|
||||||
|
/** 可取消的未验收份数 = 进行中 + 待接单 */
|
||||||
|
cancellableQuantity?: number
|
||||||
filled?: boolean
|
filled?: boolean
|
||||||
}
|
}
|
||||||
pendingUnfreezeAmount?: number
|
pendingUnfreezeAmount?: number
|
||||||
|
|||||||
Reference in New Issue
Block a user