|
|
|
@@ -393,7 +393,8 @@ export async function resolveAfterSalesCase(input: {
|
|
|
|
|
await client.query(
|
|
|
|
|
`
|
|
|
|
|
UPDATE worker_after_sales_debts
|
|
|
|
|
SET outstanding_amount = 0, status = 'settled', settled_at = $1, updated_at = $1
|
|
|
|
|
SET outstanding_amount = 0, status = 'settled',
|
|
|
|
|
settled_at = $1::timestamptz, updated_at = $1::timestamptz
|
|
|
|
|
WHERE case_id = $2 AND status = 'open'
|
|
|
|
|
`,
|
|
|
|
|
[input.now, Number(priorCase.id)],
|
|
|
|
@@ -470,21 +471,38 @@ export async function resolveAfterSalesCase(input: {
|
|
|
|
|
/** 在验收结算同一事务中抵扣未结清售后欠款,返回本次各售后单的抵扣明细。 */
|
|
|
|
|
export async function offsetWorkerAfterSalesDebtsWithClient(
|
|
|
|
|
client: PoolClient,
|
|
|
|
|
input: { workerId: number; amount: number; workOrderId: number; now: string },
|
|
|
|
|
input: {
|
|
|
|
|
workerId: number
|
|
|
|
|
amount: number
|
|
|
|
|
workOrderId: number | null
|
|
|
|
|
now: string
|
|
|
|
|
caseId?: number
|
|
|
|
|
actorType?: string
|
|
|
|
|
actorId?: string
|
|
|
|
|
},
|
|
|
|
|
): Promise<Array<{ caseId: number; amount: number }>> {
|
|
|
|
|
let remaining = Math.max(0, input.amount)
|
|
|
|
|
if (remaining <= 0) return []
|
|
|
|
|
const params: unknown[] = [input.workerId]
|
|
|
|
|
let caseFilter = ''
|
|
|
|
|
if (input.caseId && input.caseId > 0) {
|
|
|
|
|
params.push(input.caseId)
|
|
|
|
|
caseFilter = ` AND case_id = $${params.length}`
|
|
|
|
|
}
|
|
|
|
|
const debtsResult = await client.query<WorkerAfterSalesDebtRow>(
|
|
|
|
|
`
|
|
|
|
|
SELECT *
|
|
|
|
|
FROM worker_after_sales_debts
|
|
|
|
|
WHERE worker_id = $1 AND status = 'open' AND outstanding_amount > 0
|
|
|
|
|
${caseFilter}
|
|
|
|
|
ORDER BY created_at ASC, id ASC
|
|
|
|
|
FOR UPDATE
|
|
|
|
|
`,
|
|
|
|
|
[input.workerId],
|
|
|
|
|
params,
|
|
|
|
|
)
|
|
|
|
|
const offsets: Array<{ caseId: number; amount: number }> = []
|
|
|
|
|
const actorType = String(input.actorType || 'system').trim() || 'system'
|
|
|
|
|
const actorId = String(input.actorId || '').trim()
|
|
|
|
|
for (const debt of debtsResult.rows) {
|
|
|
|
|
if (remaining <= 0) break
|
|
|
|
|
const outstanding = Number(debt.outstanding_amount || 0)
|
|
|
|
@@ -495,8 +513,8 @@ export async function offsetWorkerAfterSalesDebtsWithClient(
|
|
|
|
|
`
|
|
|
|
|
UPDATE worker_after_sales_debts
|
|
|
|
|
SET outstanding_amount = $1, status = $2,
|
|
|
|
|
settled_at = CASE WHEN $2 = 'settled' THEN $3 ELSE NULL END,
|
|
|
|
|
updated_at = $3
|
|
|
|
|
settled_at = CASE WHEN $2 = 'settled' THEN $3::timestamptz ELSE NULL END,
|
|
|
|
|
updated_at = $3::timestamptz
|
|
|
|
|
WHERE id = $4
|
|
|
|
|
`,
|
|
|
|
|
[nextOutstanding, settled ? 'settled' : 'open', input.now, debt.id],
|
|
|
|
@@ -506,17 +524,21 @@ export async function offsetWorkerAfterSalesDebtsWithClient(
|
|
|
|
|
UPDATE worker_after_sales_cases
|
|
|
|
|
SET debt_amount = $1,
|
|
|
|
|
status = CASE WHEN $1 = 0 AND status = 'recovery_pending' THEN 'closed' ELSE status END,
|
|
|
|
|
updated_at = $2
|
|
|
|
|
updated_at = $2::timestamptz
|
|
|
|
|
WHERE id = $3
|
|
|
|
|
`,
|
|
|
|
|
[nextOutstanding, input.now, debt.case_id],
|
|
|
|
|
)
|
|
|
|
|
await createAfterSalesCaseEventWithClient(client, {
|
|
|
|
|
caseId: Number(debt.case_id),
|
|
|
|
|
actorType: 'system',
|
|
|
|
|
actorId: '',
|
|
|
|
|
actorType,
|
|
|
|
|
actorId,
|
|
|
|
|
eventType: settled ? 'debt_settled' : 'debt_offset',
|
|
|
|
|
payloadJson: JSON.stringify({ workOrderId: input.workOrderId, amount: offsetAmount }),
|
|
|
|
|
payloadJson: JSON.stringify({
|
|
|
|
|
workOrderId: input.workOrderId,
|
|
|
|
|
amount: offsetAmount,
|
|
|
|
|
source: actorType === 'admin' ? 'manual_repay' : 'settlement_offset',
|
|
|
|
|
}),
|
|
|
|
|
now: input.now,
|
|
|
|
|
})
|
|
|
|
|
offsets.push({ caseId: Number(debt.case_id), amount: offsetAmount })
|
|
|
|
@@ -525,6 +547,159 @@ export async function offsetWorkerAfterSalesDebtsWithClient(
|
|
|
|
|
return offsets
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 从打手可用余额偿还售后欠款(充值入账或后台手动偿还)。
|
|
|
|
|
* 不传 caseId 时按创建时间 FIFO 抵扣该打手全部 open 欠款。
|
|
|
|
|
*/
|
|
|
|
|
export async function repayWorkerAfterSalesDebtsFromAvailableWithClient(
|
|
|
|
|
client: PoolClient,
|
|
|
|
|
input: {
|
|
|
|
|
workerId: number
|
|
|
|
|
now: string
|
|
|
|
|
caseId?: number
|
|
|
|
|
maxAmount?: number
|
|
|
|
|
workOrderId?: number | null
|
|
|
|
|
note?: string
|
|
|
|
|
actorType?: string
|
|
|
|
|
actorId?: string
|
|
|
|
|
},
|
|
|
|
|
): Promise<{
|
|
|
|
|
repaidTotal: number
|
|
|
|
|
offsets: Array<{ caseId: number; amount: number }>
|
|
|
|
|
availableAfter: number
|
|
|
|
|
}> {
|
|
|
|
|
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
|
|
|
|
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
|
|
|
|
const availableAmount = Math.max(0, Number(wallet?.available_amount || 0))
|
|
|
|
|
const budget = Math.min(
|
|
|
|
|
availableAmount,
|
|
|
|
|
input.maxAmount !== undefined && input.maxAmount !== null
|
|
|
|
|
? Math.max(0, Number(input.maxAmount) || 0)
|
|
|
|
|
: availableAmount,
|
|
|
|
|
)
|
|
|
|
|
if (budget <= 0) {
|
|
|
|
|
return { repaidTotal: 0, offsets: [], availableAfter: availableAmount }
|
|
|
|
|
}
|
|
|
|
|
const offsets = await offsetWorkerAfterSalesDebtsWithClient(client, {
|
|
|
|
|
workerId: input.workerId,
|
|
|
|
|
amount: budget,
|
|
|
|
|
workOrderId: input.workOrderId ?? null,
|
|
|
|
|
now: input.now,
|
|
|
|
|
...(input.caseId ? { caseId: input.caseId } : {}),
|
|
|
|
|
...(input.actorType ? { actorType: input.actorType } : {}),
|
|
|
|
|
...(input.actorId ? { actorId: input.actorId } : {}),
|
|
|
|
|
})
|
|
|
|
|
const repaidTotal = offsets.reduce((total, item) => total + item.amount, 0)
|
|
|
|
|
if (repaidTotal <= 0) {
|
|
|
|
|
return { repaidTotal: 0, offsets: [], availableAfter: availableAmount }
|
|
|
|
|
}
|
|
|
|
|
const nextAvailable = availableAmount - repaidTotal
|
|
|
|
|
const nextFrozen = Number(wallet?.frozen_deposit_amount || 0)
|
|
|
|
|
await client.query(
|
|
|
|
|
`UPDATE worker_wallets SET available_amount = $1, updated_at = $2::timestamptz WHERE worker_id = $3`,
|
|
|
|
|
[nextAvailable, input.now, input.workerId],
|
|
|
|
|
)
|
|
|
|
|
for (const debtOffset of offsets) {
|
|
|
|
|
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, 'after_sales_debt_offset', $2, $3, $4, $5, $6, $7::jsonb, $8::timestamptz)
|
|
|
|
|
`,
|
|
|
|
|
[
|
|
|
|
|
input.workerId,
|
|
|
|
|
-debtOffset.amount,
|
|
|
|
|
nextAvailable,
|
|
|
|
|
nextFrozen,
|
|
|
|
|
input.workOrderId ?? null,
|
|
|
|
|
String(input.note || '可用余额偿还售后欠款').trim() || '可用余额偿还售后欠款',
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
caseId: debtOffset.caseId,
|
|
|
|
|
workOrderId: input.workOrderId ?? null,
|
|
|
|
|
source: input.actorType === 'admin' ? 'manual_repay' : 'balance_repay',
|
|
|
|
|
}),
|
|
|
|
|
input.now,
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return { repaidTotal, offsets, availableAfter: nextAvailable }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 后台按售后单手动从可用余额偿还欠款。 */
|
|
|
|
|
export async function repayAfterSalesCaseDebt(input: {
|
|
|
|
|
caseId: number
|
|
|
|
|
amount?: number | null
|
|
|
|
|
note: string
|
|
|
|
|
repaidBy: string
|
|
|
|
|
now: string
|
|
|
|
|
}): Promise<{
|
|
|
|
|
case: WorkerAfterSalesCaseRow | null
|
|
|
|
|
repaidAmount: number
|
|
|
|
|
availableAfter: number
|
|
|
|
|
failureReason: 'not_found' | 'no_open_debt' | 'insufficient_balance' | null
|
|
|
|
|
}> {
|
|
|
|
|
return withTransaction(async (client) => {
|
|
|
|
|
const caseResult = await client.query<WorkerAfterSalesCaseRow>(
|
|
|
|
|
`SELECT * FROM worker_after_sales_cases WHERE id = $1 FOR UPDATE`,
|
|
|
|
|
[input.caseId],
|
|
|
|
|
)
|
|
|
|
|
const afterSalesCase = caseResult.rows[0] || null
|
|
|
|
|
if (!afterSalesCase) {
|
|
|
|
|
return { case: null, repaidAmount: 0, availableAfter: 0, failureReason: 'not_found' }
|
|
|
|
|
}
|
|
|
|
|
const debtAmount = Math.max(0, Number(afterSalesCase.debt_amount || 0))
|
|
|
|
|
if (debtAmount <= 0 || afterSalesCase.status !== 'recovery_pending') {
|
|
|
|
|
return {
|
|
|
|
|
case: afterSalesCase,
|
|
|
|
|
repaidAmount: 0,
|
|
|
|
|
availableAfter: 0,
|
|
|
|
|
failureReason: 'no_open_debt',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const requestedAmount =
|
|
|
|
|
input.amount !== undefined && input.amount !== null
|
|
|
|
|
? Math.max(0, Number(input.amount) || 0)
|
|
|
|
|
: debtAmount
|
|
|
|
|
if (requestedAmount <= 0) {
|
|
|
|
|
return {
|
|
|
|
|
case: afterSalesCase,
|
|
|
|
|
repaidAmount: 0,
|
|
|
|
|
availableAfter: 0,
|
|
|
|
|
failureReason: 'no_open_debt',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const result = await repayWorkerAfterSalesDebtsFromAvailableWithClient(client, {
|
|
|
|
|
workerId: Number(afterSalesCase.worker_id),
|
|
|
|
|
now: input.now,
|
|
|
|
|
caseId: Number(afterSalesCase.id),
|
|
|
|
|
maxAmount: Math.min(requestedAmount, debtAmount),
|
|
|
|
|
workOrderId: Number(afterSalesCase.work_order_id),
|
|
|
|
|
note: input.note,
|
|
|
|
|
actorType: 'admin',
|
|
|
|
|
actorId: input.repaidBy,
|
|
|
|
|
})
|
|
|
|
|
if (result.repaidTotal <= 0) {
|
|
|
|
|
return {
|
|
|
|
|
case: afterSalesCase,
|
|
|
|
|
repaidAmount: 0,
|
|
|
|
|
availableAfter: result.availableAfter,
|
|
|
|
|
failureReason: 'insufficient_balance',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const refreshed = await client.query<WorkerAfterSalesCaseRow>(
|
|
|
|
|
`${AFTER_SALES_CASE_SELECT} WHERE ac.id = $1`,
|
|
|
|
|
[input.caseId],
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
case: refreshed.rows[0] || afterSalesCase,
|
|
|
|
|
repaidAmount: result.repaidTotal,
|
|
|
|
|
availableAfter: result.availableAfter,
|
|
|
|
|
failureReason: null,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function recoverAfterSalesAmountWithClient(
|
|
|
|
|
client: PoolClient,
|
|
|
|
|
input: {
|
|
|
|
|