You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

331 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Console\Commands;
use App\Models\Balance;
use App\Models\OrderItems;
use App\Models\Orders;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class FixOrderItemsPaidAt extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'order-items:fix-paid-at {date} {--dry-run : 试运行模式,只显示预览信息,不执行实际修复}';
/**
* The console command description.
*
* @var string
*/
protected $description = '修复指定日期 service_date 的 order_items 表中滞后于 service_date 的 paid_at 字段';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$date = $this->argument('date');
// 验证日期格式
try {
$serviceDate = Carbon::createFromFormat('Y-m-d', $date);
} catch (\Exception $e) {
$this->error("日期格式错误,请使用 Y-m-d 格式例如2025-12-01");
return 1;
}
$serviceDateStr = $serviceDate->format('Y-m-d');
$serviceDateEnd = $serviceDateStr . ' 23:59:59';
$isDryRun = $this->option('dry-run');
if ($isDryRun) {
$this->warn("=== 试运行模式:将只显示预览信息,不会执行实际修复 ===");
$this->line('');
}
$this->info("开始" . ($isDryRun ? "预览" : "修复") . " {$serviceDateStr} 的订单项...");
$this->info("目标时间点:{$serviceDateEnd}");
// 查询需要修复的记录
// 条件paid_at 的日期晚于 service_date且不在同一月份
$items = OrderItems::where('service_date', $serviceDateStr)
->whereNotNull('paid_at')
->whereRaw("DATE(`paid_at`) > '{$serviceDateStr}'")
->whereRaw("DATE_FORMAT(`paid_at`, '%Y-%m') > DATE_FORMAT('{$serviceDateStr}', '%Y-%m')")
->where('total', '>', 0)
->with(['order' => function ($query) {
$query->select('id', 'customer_id');
}])
->get();
$totalCount = $items->count();
$this->info("找到 {$totalCount} 条需要检查的记录");
if ($totalCount == 0) {
$this->info("没有需要修复的记录");
return 0;
}
$fixedCount = 0;
$skippedCount = 0;
$errorCount = 0;
$previewData = [];
if (!$isDryRun) {
$bar = $this->output->createProgressBar($totalCount);
$bar->start();
} else {
$this->line('');
$this->info("预览将要修复的记录:");
$this->line('');
}
foreach ($items as $item) {
try {
// 获取客户ID
$customerId = $item->order ? $item->order->customer_id : null;
if (!$customerId) {
if (!$isDryRun) {
$this->line('');
$this->warn("订单项 {$item->id} 没有关联的客户,跳过");
$bar->advance();
}
$skippedCount++;
continue;
}
// 计算在 service_date 时的客户余额
$balanceAtTime = $this->calculateCustomerBalanceAtTime($customerId, $serviceDateStr, $item->id);
// 判断是否满足修复条件
if ($balanceAtTime >= $item->total) {
// 满足修复条件
$originalPaidAt = $item->paid_at;
// 检查是否有关联的 balance 记录
$balanceRecord = Balance::where('belongs_type', OrderItems::class)
->where('belongs_id', $item->id)
->where('customer_id', $customerId)
->first();
if ($isDryRun) {
// 试运行模式:只收集预览信息
$previewData[] = [
'order_item_id' => $item->id,
'order_id' => $item->order_id,
'customer_id' => $customerId,
'service_date' => $item->service_date,
'total' => $item->total,
'balance_at_time' => $balanceAtTime,
'original_paid_at' => $originalPaidAt,
'new_paid_at' => $serviceDateEnd,
'has_balance_record' => $balanceRecord ? '是' : '否',
'balance_record_id' => $balanceRecord ? $balanceRecord->id : null,
];
$fixedCount++;
} else {
// 实际执行修复
DB::beginTransaction();
try {
// 更新 order_items
$item->update([
'paid_at' => $serviceDateEnd,
'paid_at_original' => $originalPaidAt
]);
// 更新关联的 balance 记录
if ($balanceRecord) {
$balanceRecord->update([
'created_at' => $serviceDateEnd
]);
} else {
$this->line('');
$this->warn("订单项 {$item->id} 没有找到关联的 balance 记录");
}
DB::commit();
$fixedCount++;
Log::info("修复订单项 {$item->id}:原 paid_at {$originalPaidAt} -> {$serviceDateEnd}");
} catch (\Exception $e) {
DB::rollBack();
$errorCount++;
$this->line('');
$this->error("修复订单项 {$item->id} 失败:" . $e->getMessage());
Log::error("修复订单项 {$item->id} 失败:" . $e->getMessage());
}
}
} else {
// 余额不足,不满足修复条件
if ($isDryRun) {
$previewData[] = [
'order_item_id' => $item->id,
'order_id' => $item->order_id,
'customer_id' => $customerId,
'service_date' => $item->service_date,
'total' => $item->total,
'balance_at_time' => $balanceAtTime,
'original_paid_at' => $item->paid_at,
'new_paid_at' => '不满足条件',
'has_balance_record' => '-',
'balance_record_id' => null,
'skip_reason' => '余额不足(余额:' . $balanceAtTime . ' < 金额:' . $item->total . '',
];
}
$skippedCount++;
}
} catch (\Exception $e) {
$errorCount++;
if (!$isDryRun) {
$this->line('');
$this->error("处理订单项 {$item->id} 时出错:" . $e->getMessage());
}
Log::error("处理订单项 {$item->id} 时出错:" . $e->getMessage());
}
if (!$isDryRun) {
$bar->advance();
}
}
if (!$isDryRun) {
$bar->finish();
}
$this->line('');
$this->line('');
// 试运行模式:显示详细预览信息
if ($isDryRun && !empty($previewData)) {
$this->info("=== 预览详情 ===");
$this->line('');
// 显示满足条件的记录
$willFix = array_filter($previewData, function($item) {
return !isset($item['skip_reason']);
});
if (!empty($willFix)) {
$this->info("满足条件将修复的记录(" . count($willFix) . " 条):");
$this->line('');
$this->table(
['订单项ID', '订单ID', '客户ID', '服务日期', '金额', '当时余额', '原paid_at', '新paid_at', '有balance记录'],
array_map(function($item) {
return [
$item['order_item_id'],
$item['order_id'],
$item['customer_id'],
$item['service_date'],
$item['total'],
$item['balance_at_time'],
$item['original_paid_at'],
$item['new_paid_at'],
$item['has_balance_record'],
];
}, $willFix)
);
$this->line('');
}
// 显示跳过的记录
$willSkip = array_filter($previewData, function($item) {
return isset($item['skip_reason']);
});
if (!empty($willSkip)) {
$this->warn("不满足条件将跳过的记录(" . count($willSkip) . " 条):");
$this->line('');
$this->table(
['订单项ID', '订单ID', '客户ID', '服务日期', '金额', '当时余额', '原paid_at', '跳过原因'],
array_map(function($item) {
return [
$item['order_item_id'],
$item['order_id'],
$item['customer_id'],
$item['service_date'],
$item['total'],
$item['balance_at_time'],
$item['original_paid_at'],
$item['skip_reason'],
];
}, $willSkip)
);
$this->line('');
}
}
// 输出统计信息
$this->info(($isDryRun ? "预览" : "修复") . "完成!");
$this->table(
['统计项', '数量'],
[
['总记录数', $totalCount],
['满足条件' . ($isDryRun ? '(将修复)' : '并修复'), $fixedCount],
['余额不足跳过', $skippedCount],
['处理失败', $errorCount],
]
);
if ($isDryRun) {
$this->line('');
$this->comment("提示:这是试运行模式,没有执行实际修复。");
$this->comment("要执行实际修复,请运行命令时不加 --dry-run 参数。");
}
return 0;
}
/**
* 计算指定时间点的客户余额
*
* @param int $customerId
* @param string $datetime
* @param int $excludeOrderItemId 排除当前订单项(因为它在修复前的时间点还不存在)
* @return float
*/
private function calculateCustomerBalanceAtTime($customerId, $datetime, $excludeOrderItemId = null)
{
// 查询在该时间点之前的所有 balance 记录
$query = Balance::where('customer_id', $customerId)
->where('created_at', '<=', $datetime)
->whereNull('deleted_at');
// 排除当前订单项的 balance 记录(因为它在修复前的时间点还不存在)
// 需要排除 belongs_type 为 OrderItems 且 belongs_id 为当前订单项 ID 的记录
if ($excludeOrderItemId) {
$query->where(function ($q) use ($excludeOrderItemId) {
$q->where('belongs_type', '!=', OrderItems::class)
->orWhere(function ($subQ) use ($excludeOrderItemId) {
$subQ->where('belongs_type', OrderItems::class)
->where('belongs_id', '!=', $excludeOrderItemId);
});
});
}
// 累加所有 money 字段money 为正数表示充值/退款,负数表示扣款)
$balance = $query->sum('money');
return (float) $balance;
}
}