fix(settlement): 操作记录分页、流水动作与坏账幂等加固
- 新增 settlement-operations 合并列表 API,收付/调账接口改为标准分页 - actionable_only 流水在内存过滤后正确分页 - 已结账单流水不再展示补差/冲正快捷动作 - 坏账核销支持 idempotency_key 并写入 result_bill_id - 补充操作记录、流水与坏账幂等相关测试
This commit is contained in:
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user