Merge branch 'dev1023' into dev

This commit is contained in:
2026-10-26 15:26:43 +08:00
16 changed files with 860 additions and 43 deletions
@@ -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
@@ -1254,6 +1256,9 @@ export function buildWorkOrderWhere({
FROM work_order_shares wos
WHERE wos.work_order_id = wo.id
AND wos.worker_id = ${workerSharePlaceholder}::bigint
-- 当前接单人就是本人时,旧的已取消份额不能再代表这张单:
-- 撤单后把同一单重新指派给同一打手,否则列表会一直判成「已取消」。
AND NOT (wos.status = 'cancelled' AND wo.assigned_worker_id = ${workerSharePlaceholder}::bigint)
ORDER BY
CASE wos.status
WHEN 'joined' THEN 0
@@ -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'
@@ -253,6 +253,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
@@ -311,6 +312,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:
@@ -374,11 +379,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),
@@ -167,11 +167,14 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const cancelledShare = cancelledShareByOrderId.get(workOrderId)
const timeoutEvent = timeoutEventsByOrderId.get(workOrderId)
const assignedToMe =
item.assigned_worker_id != null && Number(item.assigned_worker_id) === Number(worker.id)
// 当前接单人就是本人时,旧的已取消份额只作历史留痕,不能再参与状态判定:
// 撤单后重新指派给同一打手,否则会被映射成「已取消」,按钮全部不可用。
const fallbackCancelledShare = assignedToMe ? null : cancelledShare
// 判断当前打手是否仍持有该单(任一有效份额即算持有)
const isOwnerCurrently =
(item.assigned_worker_id != null &&
Number(item.assigned_worker_id) === Number(worker.id)) ||
activeShares.length > 0
const isOwnerCurrently = assignedToMe || activeShares.length > 0
// 若不是当前持有人,但有该打手的超时事件或取消份额,则判定为该打手的已超时历史单
const isTimedOutForMe =
@@ -219,7 +222,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
...mapWorkOrderForWorker(
item,
permissions,
activeShare || cancelledShare || null,
activeShare || fallbackCancelledShare || null,
noteByOrderId.get(workOrderId) || '',
viewedAtByOrderId.get(workOrderId) || null,
{ isTimedOutForMe, timeoutNotice, myShares: activeShares },
@@ -3610,3 +3610,76 @@ 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 能力位')
})
test('撤单后重派同一打手:旧已取消份额不得把当前接单状态判成已取消', () => {
const querySrc = fs.readFileSync(
new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url),
'utf8',
)
assert.ok(
querySrc.includes(
"AND NOT (wos.status = 'cancelled' AND wo.assigned_worker_id = ${workerSharePlaceholder}::bigint)",
),
'当前接单人就是本人时,代表份额必须排除已取消份额,否则列表会判成「已取消」',
)
const serviceSrc = fs.readFileSync(
new URL('./worker-my-order-service.ts', import.meta.url),
'utf8',
)
assert.ok(
serviceSrc.includes('const assignedToMe =') &&
serviceSrc.includes('const fallbackCancelledShare = assignedToMe ? null : cancelledShare'),
'当前接单人就是本人时,不得把已取消份额当 myShare 传给映射',
)
assert.ok(
serviceSrc.includes('activeShare || fallbackCancelledShare || null'),
'列表映射必须使用排除后的已取消份额兜底',
)
})
@@ -440,6 +440,24 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
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':
return { title: '扣减了待解冻押金', color: 'default' }
case 'voucher_backfilled':
@@ -56,6 +56,7 @@ import {
cancelAdminWorkOrder,
cancelAdminWorkOrderShare,
cancelAdminWorkOrderSharing,
cancelAdminWorkOrderSharingPart,
completeAdminWorkOrderGiftCooldown,
confirmAdminWorkOrderFriend,
deductAdminWorkOrderPendingDeposit,
@@ -185,6 +186,11 @@ type CancelSharingFormValues = {
reason?: string
}
type CancelSharingPartFormValues = {
refundAmount?: number
reason?: string
}
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
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 [disposeShare, setDisposeShare] = useState<WorkOrderShare | null>(null)
const [cancelSharingOrder, setCancelSharingOrder] = useState<WorkOrder | null>(null)
const [cancelSharingPartOrder, setCancelSharingPartOrder] = useState<WorkOrder | null>(null)
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
@@ -329,6 +336,7 @@ export default function WorkOrdersPanel() {
note?: string
}>()
const [cancelSharingForm] = Form.useForm<CancelSharingFormValues>()
const [cancelSharingPartForm] = Form.useForm<CancelSharingPartFormValues>()
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
const [editForm] = Form.useForm<{
productName?: string
@@ -551,6 +559,18 @@ export default function WorkOrdersPanel() {
['joined', 'submitted'].includes(share.status),
)
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({
queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword],
queryFn: () =>
@@ -821,6 +841,48 @@ export default function WorkOrdersPanel() {
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() {
await Promise.all([
queryClient.invalidateQueries({
@@ -3114,6 +3176,7 @@ export default function WorkOrdersPanel() {
setCancelShare(null)
setReduceShare(null)
setCancelSharingOrder(null)
setCancelSharingPartOrder(null)
sharingForm.resetFields()
cancelShareForm.resetFields()
reduceShareForm.resetFields()
@@ -3201,6 +3264,26 @@ export default function WorkOrdersPanel() {
</Form>
<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 ? (
<Typography.Text type="secondary">加载中...</Typography.Text>
) : (sharingQuery.data?.data.shares || []).length === 0 ? (
@@ -3782,6 +3865,60 @@ export default function WorkOrdersPanel() {
) : null}
</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
title={
@@ -942,6 +942,24 @@ export function cancelAdminWorkOrderSharing(
}>(`/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[]) {
return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', {
workOrderIds,
@@ -648,6 +648,8 @@ export type WorkOrder = {
canPublish: boolean
canCancel: boolean
canCancelSharing?: boolean
/** 取消部分:保留已验收份额,取消进行中份额与待接单剩余份数。 */
canCancelSharingPart?: boolean
canResolveProblem: boolean
canDeduct: boolean
canConfirmFriend?: boolean
@@ -655,6 +657,8 @@ export type WorkOrder = {
canResetGiftFlow?: boolean
}
rewardAmount: number
/** 来源订单实付金额(分):取消部分时用于预填退款金额。 */
sourceOrderAmount?: number
/** 当前打手生效的免押额度,仅打手端订单数据返回。 */
depositFreeAmount?: number
requiredDepositAmount?: number
@@ -668,6 +672,10 @@ export type WorkOrder = {
joinedQuantity: number
pendingSubmissionCount: number
remainingQuantity?: number
/** 已验收份额的份数合计 */
acceptedQuantity?: number
/** 可取消的未验收份数 = 进行中 + 待接单 */
cancellableQuantity?: number
filled?: boolean
}
pendingUnfreezeAmount?: number