fix(settlement): 操作记录分页、流水动作与坏账幂等加固
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

- 新增 settlement-operations 合并列表 API,收付/调账接口改为标准分页
- actionable_only 流水在内存过滤后正确分页
- 已结账单流水不再展示补差/冲正快捷动作
- 坏账核销支持 idempotency_key 并写入 result_bill_id
- 补充操作记录、流水与坏账幂等相关测试
This commit is contained in:
2026-06-26 16:40:46 +08:00
parent 7ec8f4c5a2
commit c83343e989
20 changed files with 1045 additions and 212 deletions

View File

@@ -3,8 +3,10 @@
namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Services\Player\PlayerCreditService;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use App\Support\DatabaseUniqueViolation;
use App\Services\Player\PlayerCreditService;
use Illuminate\Validation\ValidationException;
/** 坏账核销:原账单保留,记 bad_debt 调整与归档单§2、§21.1)。 */
@@ -15,112 +17,167 @@ final class AgentSettlementBadDebtService
private readonly PlayerCreditService $playerCreditService,
) {}
public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int
{
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');
}
$meta = $this->decodeMeta($original->meta_json);
$existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0);
if ($existingArchiveId > 0) {
public function writeOff(
int $originalBillId,
?string $reason,
int $adminUserId,
?string $idempotencyKey = null,
): int {
if ($idempotencyKey !== null && $idempotencyKey !== '') {
$existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey);
if ($existingArchiveId !== null) {
return $existingArchiveId;
}
}
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
throw ValidationException::withMessages([
'period' => ['completed'],
]);
}
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], 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'],
]);
}
$now = now();
$periodId = (int) $original->settlement_period_id;
$archiveBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'bad_debt',
'owner_type' => (string) $original->owner_type,
'owner_id' => (int) $original->owner_id,
'counterparty_type' => (string) $original->counterparty_type,
'counterparty_id' => (int) $original->counterparty_id,
'gross_win_loss' => 0,
'rebate_amount' => 0,
'adjustment_amount' => -$unpaid,
'platform_rounding_adjustment' => 0,
'net_amount' => 0,
'paid_amount' => 0,
'unpaid_amount' => 0,
'status' => 'settled',
'reversed_bill_id' => $originalBillId,
'meta_json' => json_encode([
'original_bill_id' => $originalBillId,
'written_off_amount' => $unpaid,
'original_net_amount' => (int) $original->net_amount,
]),
'locked_at' => $now,
'confirmed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_adjustments')->insert([
'settlement_period_id' => $periodId,
'original_bill_id' => $originalBillId,
'adjustment_type' => 'bad_debt',
'amount' => $unpaid,
'reason' => $reason,
'created_by' => $adminUserId > 0 ? $adminUserId : null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_bills')->where('id', $originalBillId)->update([
'unpaid_amount' => 0,
'status' => 'settled',
'meta_json' => json_encode(array_merge(
$meta,
[
'bad_debt_bill_id' => $archiveBillId,
'written_off_amount' => $unpaid,
],
)),
'updated_at' => $now,
]);
if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) {
$player = Player::query()->find((int) $original->owner_id);
if ($player !== null) {
$cumulativePaid = (int) $original->paid_amount + $unpaid;
$this->playerCreditService->releaseFromSettlement($player, $cumulativePaid, $originalBillId);
try {
return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId, $idempotencyKey): int {
return $this->insertBadDebtWriteOff($originalBillId, $reason, $adminUserId, $idempotencyKey);
});
} catch (QueryException $e) {
if (
$idempotencyKey !== null
&& $idempotencyKey !== ''
&& DatabaseUniqueViolation::matches($e)
) {
$existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey);
if ($existingArchiveId !== null) {
return $existingArchiveId;
}
}
$this->periodCompletion->syncIfReady($periodId);
throw $e;
}
}
return $archiveBillId;
});
private function insertBadDebtWriteOff(
int $originalBillId,
?string $reason,
int $adminUserId,
?string $idempotencyKey,
): int {
/** @var object|null $original */
$original = DB::table('settlement_bills')->where('id', $originalBillId)->lockForUpdate()->first();
if ($original === null) {
throw new \InvalidArgumentException('bill_not_found');
}
$meta = $this->decodeMeta($original->meta_json);
$existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0);
if ($existingArchiveId > 0) {
return $existingArchiveId;
}
if ($idempotencyKey !== null && $idempotencyKey !== '') {
$existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey);
if ($existingArchiveId !== null) {
return $existingArchiveId;
}
}
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
throw ValidationException::withMessages([
'period' => ['completed'],
]);
}
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], 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'],
]);
}
$now = now();
$periodId = (int) $original->settlement_period_id;
$archiveBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'bad_debt',
'owner_type' => (string) $original->owner_type,
'owner_id' => (int) $original->owner_id,
'counterparty_type' => (string) $original->counterparty_type,
'counterparty_id' => (int) $original->counterparty_id,
'gross_win_loss' => 0,
'rebate_amount' => 0,
'adjustment_amount' => -$unpaid,
'platform_rounding_adjustment' => 0,
'net_amount' => 0,
'paid_amount' => 0,
'unpaid_amount' => 0,
'status' => 'settled',
'reversed_bill_id' => $originalBillId,
'meta_json' => json_encode([
'original_bill_id' => $originalBillId,
'written_off_amount' => $unpaid,
'original_net_amount' => (int) $original->net_amount,
]),
'locked_at' => $now,
'confirmed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_adjustments')->insert([
'settlement_period_id' => $periodId,
'original_bill_id' => $originalBillId,
'result_bill_id' => $archiveBillId,
'adjustment_type' => 'bad_debt',
'amount' => $unpaid,
'reason' => $reason,
'idempotency_key' => $idempotencyKey,
'created_by' => $adminUserId > 0 ? $adminUserId : null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_bills')->where('id', $originalBillId)->update([
'unpaid_amount' => 0,
'status' => 'settled',
'meta_json' => json_encode(array_merge(
$meta,
[
'bad_debt_bill_id' => $archiveBillId,
'written_off_amount' => $unpaid,
],
)),
'updated_at' => $now,
]);
if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) {
$player = Player::query()->find((int) $original->owner_id);
if ($player !== null) {
$cumulativePaid = (int) $original->paid_amount + $unpaid;
$this->playerCreditService->releaseFromSettlement($player, $cumulativePaid, $originalBillId);
}
}
$this->periodCompletion->syncIfReady($periodId);
return $archiveBillId;
}
private function findArchiveBillByIdempotency(int $originalBillId, string $idempotencyKey): ?int
{
$resultBillId = DB::table('settlement_adjustments')
->where('original_bill_id', $originalBillId)
->where('adjustment_type', 'bad_debt')
->where('idempotency_key', $idempotencyKey)
->value('result_bill_id');
return $resultBillId !== null ? (int) $resultBillId : null;
}
/**
@@ -140,4 +197,4 @@ final class AgentSettlementBadDebtService
return is_array($decoded) ? $decoded : [];
}
}
}