fix(funds): 加固转账冲正、结算收付幂等与坏账核销
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

- 转入 main_site_timeout 等不可冲正场景直接拒绝,避免假结案
- payment_records / settlement_adjustments 增加 partial unique 索引
- 坏账核销行锁 + meta 幂等回放;补差单记录 result_bill_id
- 新增 FundOperationsHardeningTest 覆盖关键路径
This commit is contained in:
2026-06-26 15:19:17 +08:00
parent 3f04b4ebe3
commit 7ec8f4c5a2
8 changed files with 535 additions and 104 deletions

View File

@@ -17,37 +17,44 @@ final class AgentSettlementBadDebtService
public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int
{
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
if ($original === null) {
throw new \InvalidArgumentException('bill_not_found');
}
return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId): int {
/** @var object|null $original */
$original = DB::table('settlement_bills')->where('id', $originalBillId)->lockForUpdate()->first();
if ($original === null) {
throw new \InvalidArgumentException('bill_not_found');
}
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
throw ValidationException::withMessages([
'period' => ['completed'],
]);
}
$meta = $this->decodeMeta($original->meta_json);
$existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0);
if ($existingArchiveId > 0) {
return $existingArchiveId;
}
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
throw ValidationException::withMessages([
'period' => ['completed'],
]);
}
$unpaid = (int) $original->unpaid_amount;
if ($unpaid <= 0) {
throw ValidationException::withMessages([
'bill' => ['no_unpaid'],
]);
}
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
$unpaid = (int) $original->unpaid_amount;
if ($unpaid <= 0) {
throw ValidationException::withMessages([
'bill' => ['no_unpaid'],
]);
}
if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
return (int) DB::transaction(function () use ($original, $originalBillId, $unpaid, $reason, $adminUserId): int {
$now = now();
$periodId = (int) $original->settlement_period_id;
@@ -93,7 +100,7 @@ final class AgentSettlementBadDebtService
'unpaid_amount' => 0,
'status' => 'settled',
'meta_json' => json_encode(array_merge(
$this->decodeMeta($original->meta_json),
$meta,
[
'bad_debt_bill_id' => $archiveBillId,
'written_off_amount' => $unpaid,
@@ -133,4 +140,4 @@ final class AgentSettlementBadDebtService
return is_array($decoded) ? $decoded : [];
}
}
}