|
|
|
@@ -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,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|