548 lines
17 KiB
PHP
548 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Services\ActivityLogger;
|
|
use App\Models\Contract;
|
|
use App\Models\Customer;
|
|
use App\Models\Feedback;
|
|
use App\Models\FeedbackInteraction;
|
|
use App\Models\Handover;
|
|
use App\Models\Product;
|
|
use App\Models\ProductService;
|
|
use App\Models\User;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Spatie\SimpleExcel\SimpleExcelReader;
|
|
|
|
class ImportCskh extends Command
|
|
{
|
|
protected $signature = 'app:import-cskh
|
|
{file_path : Path to CSV/Excel file}
|
|
{--dry-run : Analyze only, do not write to database}';
|
|
|
|
protected $description = 'Import dữ liệu CSKH từ file Excel/CSV vào database relational (V2)';
|
|
|
|
private int $statsProducts = 0;
|
|
private int $statsCustomers = 0;
|
|
private int $statsContracts = 0;
|
|
private int $statsHandovers = 0;
|
|
private int $statsServices = 0;
|
|
private int $statsFeedbacks = 0;
|
|
private int $statsInteractions = 0;
|
|
private int $statsSkipped = 0;
|
|
private int $statsSkippedEmptyCode = 0;
|
|
private int $statsSkippedNumericCode = 0;
|
|
private int $statsSkippedHeaderRow = 0;
|
|
private int $statsSkippedEmptyCustomer = 0;
|
|
private array $skippedRowDetails = [];
|
|
|
|
public function handle(): int
|
|
{
|
|
$filePath = $this->argument('file_path');
|
|
$dryRun = $this->option('dry-run');
|
|
|
|
if (! file_exists($filePath)) {
|
|
$this->error("File not found: {$filePath}");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$this->warn('=== DRY RUN MODE — No data will be written ===');
|
|
}
|
|
|
|
$startTime = now();
|
|
|
|
$rows = SimpleExcelReader::create($filePath)->getRows();
|
|
|
|
$total = iterator_count($rows);
|
|
$rows = SimpleExcelReader::create($filePath)->getRows();
|
|
|
|
$this->info("Total rows in file: {$total}");
|
|
$bar = $this->output->createProgressBar($total);
|
|
$bar->start();
|
|
|
|
$chunkSize = 100;
|
|
$chunk = [];
|
|
$rowCount = 0;
|
|
|
|
foreach ($rows as $row) {
|
|
$rowCount++;
|
|
|
|
$metaReason = $this->getMetaRowReason($row);
|
|
if ($metaReason !== null) {
|
|
$this->recordSkip($rowCount, $metaReason, $row);
|
|
$bar->advance();
|
|
|
|
continue;
|
|
}
|
|
|
|
$chunk[] = $row;
|
|
|
|
if (count($chunk) >= $chunkSize) {
|
|
$this->processChunk($chunk, $dryRun, $rowCount);
|
|
$chunk = [];
|
|
}
|
|
|
|
$bar->advance();
|
|
}
|
|
|
|
if (! empty($chunk)) {
|
|
$this->processChunk($chunk, $dryRun, $rowCount);
|
|
}
|
|
|
|
$bar->finish();
|
|
$this->newLine(2);
|
|
$this->printSummary($rowCount, $dryRun);
|
|
|
|
$duration = now()->diffInSeconds($startTime);
|
|
$modeLabel = $dryRun ? '[DRY RUN] ' : '';
|
|
|
|
ActivityLogger::log(
|
|
'import',
|
|
$modeLabel . __('feedback.log_import_done', ['file' => basename($filePath), 'rows' => $rowCount]),
|
|
null,
|
|
[
|
|
'file' => $filePath,
|
|
'total_rows' => $rowCount,
|
|
'skipped' => $this->statsSkipped,
|
|
'products' => $this->statsProducts,
|
|
'customers' => $this->statsCustomers,
|
|
'contracts' => $this->statsContracts,
|
|
'handovers' => $this->statsHandovers,
|
|
'services' => $this->statsServices,
|
|
'feedbacks' => $this->statsFeedbacks,
|
|
'interactions' => $this->statsInteractions,
|
|
'duration_seconds' => $duration,
|
|
'dry_run' => $dryRun,
|
|
],
|
|
);
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function getMetaRowReason(array $row): ?string
|
|
{
|
|
$code = $row['Mã căn hộ'] ?? '';
|
|
|
|
if (empty($code)) {
|
|
return 'empty_code';
|
|
}
|
|
|
|
if (is_numeric($code)) {
|
|
return 'numeric_code';
|
|
}
|
|
|
|
if ($code === 'Ngày TT' || $code === 'Tình trạng' || $code === 'Tên DN') {
|
|
return 'header_row';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function recordSkip(int $rowNum, string $reason, array $row): void
|
|
{
|
|
$this->statsSkipped++;
|
|
|
|
match ($reason) {
|
|
'empty_code' => $this->statsSkippedEmptyCode++,
|
|
'numeric_code' => $this->statsSkippedNumericCode++,
|
|
'header_row' => $this->statsSkippedHeaderRow++,
|
|
default => null,
|
|
};
|
|
|
|
$code = $row['Mã căn hộ'] ?? '';
|
|
$this->skippedRowDetails[] = [
|
|
'row' => $rowNum,
|
|
'reason' => $reason,
|
|
'code' => $code,
|
|
];
|
|
}
|
|
|
|
private function processChunk(array $rows, bool $dryRun, int $currentRowNum): void
|
|
{
|
|
if ($dryRun) {
|
|
foreach ($rows as $i => $row) {
|
|
$this->processRow($row, true, $currentRowNum - count($rows) + $i + 1);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($rows, $currentRowNum): void {
|
|
foreach ($rows as $i => $row) {
|
|
$this->processRow($row, false, $currentRowNum - count($rows) + $i + 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
private function processRow(array $row, bool $dryRun, int $rowNum): void
|
|
{
|
|
$productCode = trim($row['Mã căn hộ'] ?? '');
|
|
$customerName = trim($row['Họ tên KH'] ?? '');
|
|
|
|
if (empty($productCode) || empty($customerName)) {
|
|
$this->statsSkipped++;
|
|
$this->statsSkippedEmptyCustomer++;
|
|
$this->skippedRowDetails[] = [
|
|
'row' => $rowNum,
|
|
'reason' => 'empty_customer',
|
|
'code' => $productCode,
|
|
];
|
|
|
|
return;
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$this->statsProducts++;
|
|
$this->statsCustomers++;
|
|
|
|
return;
|
|
}
|
|
|
|
$product = $this->findOrCreateProduct($productCode);
|
|
$customer = $this->findOrCreateCustomer($customerName);
|
|
|
|
$this->processContracts($row, $product, $customer);
|
|
$this->processHandover($row, $product, $customer);
|
|
$this->processServices($row, $product);
|
|
|
|
$content = $this->extractContent($row);
|
|
if (! empty($content)) {
|
|
$this->processFeedback($content, $product, $customer);
|
|
}
|
|
}
|
|
|
|
private function findOrCreateProduct(string $code): Product
|
|
{
|
|
$p = Product::firstOrCreate(
|
|
['name' => $code],
|
|
['status' => 'active']
|
|
);
|
|
|
|
if ($p->wasRecentlyCreated) {
|
|
$this->statsProducts++;
|
|
}
|
|
|
|
return $p;
|
|
}
|
|
|
|
private function findOrCreateCustomer(string $name): Customer
|
|
{
|
|
$c = Customer::firstOrCreate(
|
|
['name' => $name],
|
|
['status' => 'active']
|
|
);
|
|
|
|
if ($c->wasRecentlyCreated) {
|
|
$this->statsCustomers++;
|
|
}
|
|
|
|
return $c;
|
|
}
|
|
|
|
private function processContracts(array $row, Product $product, Customer $customer): void
|
|
{
|
|
$leaseValue = $row['HỢP ĐỒNG THUÊ'] ?? null;
|
|
$leaseStr = is_object($leaseValue) ? null : (string) ($leaseValue ?? '');
|
|
if (! empty($leaseStr) && ! is_numeric($leaseStr) && $leaseStr !== 'Tình trạng') {
|
|
$exists = Contract::where('product_id', $product->id)
|
|
->where('customer_id', $customer->id)
|
|
->where('category', 'business')
|
|
->where('type', 'rental')
|
|
->exists();
|
|
|
|
if (! $exists) {
|
|
Contract::create([
|
|
'product_id' => $product->id,
|
|
'customer_id' => $customer->id,
|
|
'category' => 'business',
|
|
'type' => 'rental',
|
|
'status' => 'active',
|
|
]);
|
|
$this->statsContracts++;
|
|
}
|
|
}
|
|
|
|
$bccValue = $row['HĐ HỢP TÁC KINH DOANH'] ?? null;
|
|
$bccStr = is_object($bccValue) ? null : (string) ($bccValue ?? '');
|
|
if (! empty($bccStr) && ! is_numeric($bccStr) && $bccStr !== 'Tình trạng HĐ') {
|
|
$exists = Contract::where('product_id', $product->id)
|
|
->where('customer_id', $customer->id)
|
|
->where('category', 'business')
|
|
->where('type', 'bcc')
|
|
->exists();
|
|
|
|
if (! $exists) {
|
|
Contract::create([
|
|
'product_id' => $product->id,
|
|
'customer_id' => $customer->id,
|
|
'category' => 'business',
|
|
'type' => 'bcc',
|
|
'status' => 'active',
|
|
'signed_status' => $bccStr,
|
|
]);
|
|
$this->statsContracts++;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function processHandover(array $row, Product $product, Customer $customer): void
|
|
{
|
|
$handoverDate = $this->parseHandoverDate($row['BÀN GIÀO CĂN HỘ'] ?? null);
|
|
$statusNote = $row['TÌNH TRẠNG'] ?? null;
|
|
$statusStr = is_object($statusNote) ? null : $statusNote;
|
|
|
|
if (empty($handoverDate) && empty($statusStr)) {
|
|
return;
|
|
}
|
|
|
|
$exists = Handover::where('product_id', $product->id)
|
|
->where('customer_id', $customer->id)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
return;
|
|
}
|
|
|
|
Handover::create([
|
|
'product_id' => $product->id,
|
|
'customer_id' => $customer->id,
|
|
'handover_date' => $handoverDate,
|
|
'status' => $handoverDate ? 'da_ban_giao' : 'chua_du_dieu_kien',
|
|
'remark' => $this->truncateRemark($statusStr),
|
|
]);
|
|
$this->statsHandovers++;
|
|
}
|
|
|
|
private function processServices(array $row, Product $product): void
|
|
{
|
|
$selfBiz = $row['TỰ DOANH'] ?? null;
|
|
$selfBizStr = is_object($selfBiz) ? null : (string) ($selfBiz ?? '');
|
|
if (! empty($selfBizStr) && ! is_numeric($selfBizStr) && $selfBizStr !== 'Tên DN') {
|
|
$exists = ProductService::where('product_id', $product->id)
|
|
->where('service_type', 'self_business')
|
|
->exists();
|
|
|
|
if (! $exists) {
|
|
ProductService::create([
|
|
'product_id' => $product->id,
|
|
'service_type' => 'self_business',
|
|
'status' => 'active',
|
|
'remark' => $selfBizStr,
|
|
]);
|
|
$this->statsServices++;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function extractContent(array $row): ?string
|
|
{
|
|
$sources = [
|
|
$row['Lịch Sử khách hàng'] ?? null,
|
|
$row['Nội dung'] ?? null,
|
|
$row['TÌNH TRẠNG'] ?? null,
|
|
];
|
|
|
|
foreach ($sources as $src) {
|
|
if (is_object($src)) {
|
|
continue;
|
|
}
|
|
if (! empty($src) && ! is_numeric($src)) {
|
|
return (string) $src;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function processFeedback(string $content, Product $product, Customer $customer): void
|
|
{
|
|
$activeContract = Contract::where('product_id', $product->id)
|
|
->where('status', 'active')
|
|
->first();
|
|
|
|
$lines = array_values(array_filter(
|
|
explode("\n", str_replace("\r\n", "\n", $content)),
|
|
fn ($l) => trim($l) !== ''
|
|
));
|
|
|
|
if (empty($lines)) {
|
|
return;
|
|
}
|
|
|
|
$title = 'Ticket từ dữ liệu lịch sử';
|
|
|
|
$feedback = Feedback::firstOrCreate(
|
|
[
|
|
'customer_id' => $customer->id,
|
|
'contract_id' => $activeContract?->id,
|
|
'title' => $title,
|
|
],
|
|
[
|
|
'content' => $content,
|
|
'status' => 'pending',
|
|
'feedback_channel_id' => 1,
|
|
]
|
|
);
|
|
|
|
if ($feedback->wasRecentlyCreated) {
|
|
$this->statsFeedbacks++;
|
|
}
|
|
|
|
$defaultUser = User::first();
|
|
|
|
foreach ($lines as $line) {
|
|
$exists = FeedbackInteraction::where('feedback_id', $feedback->id)
|
|
->where('content', $line)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
continue;
|
|
}
|
|
|
|
FeedbackInteraction::create([
|
|
'feedback_id' => $feedback->id,
|
|
'user_id' => $defaultUser?->id,
|
|
'type' => 'note',
|
|
'content' => mb_strlen($line) > 500 ? mb_substr($line, 0, 500) : $line,
|
|
]);
|
|
$this->statsInteractions++;
|
|
}
|
|
}
|
|
|
|
private function parseHandoverDate(mixed $value): ?string
|
|
{
|
|
if (empty($value)) {
|
|
return null;
|
|
}
|
|
|
|
if ($value instanceof \DateTimeInterface) {
|
|
return $value->format('Y-m-d');
|
|
}
|
|
|
|
if (is_object($value)) {
|
|
return null;
|
|
}
|
|
|
|
if (is_string($value) && str_starts_with($value, '{')) {
|
|
$decoded = json_decode($value, true);
|
|
|
|
if (is_array($decoded) && ! empty($decoded['date'])) {
|
|
$timestamp = strtotime($decoded['date']);
|
|
|
|
return $timestamp ? date('Y-m-d', $timestamp) : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
return $this->parseDateString((string) $value);
|
|
}
|
|
|
|
private function parseDateString(string $value): ?string
|
|
{
|
|
$value = trim($value);
|
|
$patterns = [
|
|
'/^\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}$/',
|
|
'/^\d{4}[\/\-\.]\d{1,2}[\/\-\.]\d{1,2}$/',
|
|
];
|
|
|
|
foreach ($patterns as $pattern) {
|
|
if (preg_match($pattern, $value)) {
|
|
$cleaned = str_replace('/', '-', $value);
|
|
$timestamp = strtotime($cleaned);
|
|
|
|
return $timestamp ? date('Y-m-d', $timestamp) : null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function truncateRemark(?string $value): ?string
|
|
{
|
|
if (empty($value) || is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
return mb_strlen($value) > 500 ? mb_substr($value, 0, 500) : $value;
|
|
}
|
|
|
|
private function printSummary(int $totalRows, bool $dryRun): void
|
|
{
|
|
$mode = $dryRun ? '[DRY RUN] ' : '';
|
|
|
|
$this->table(
|
|
['Metric', 'Count'],
|
|
[
|
|
['Total rows in file', $totalRows],
|
|
['Skipped (meta/empty)', $this->statsSkipped],
|
|
[' ├─ Empty code (row trống)', $this->statsSkippedEmptyCode],
|
|
[' ├─ Numeric code (hàng tổng)', $this->statsSkippedNumericCode],
|
|
[' ├─ Header row (tiêu đề)', $this->statsSkippedHeaderRow],
|
|
[' └─ Empty customer name', $this->statsSkippedEmptyCustomer],
|
|
[$mode . 'New Products', $this->statsProducts],
|
|
[$mode . 'New Customers', $this->statsCustomers],
|
|
[$mode . 'New Contracts', $this->statsContracts],
|
|
[$mode . 'New Handovers', $this->statsHandovers],
|
|
[$mode . 'New Services', $this->statsServices],
|
|
[$mode . 'New Feedbacks', $this->statsFeedbacks],
|
|
[$mode . 'New Interactions', $this->statsInteractions],
|
|
]
|
|
);
|
|
|
|
if ($dryRun && ! empty($this->skippedRowDetails)) {
|
|
$this->newLine();
|
|
$this->warn('=== Chi tiết các dòng bị bỏ qua ===');
|
|
$this->newLine();
|
|
|
|
$reasonLabels = [
|
|
'empty_code' => '<fg=red>Trống mã căn hộ</>',
|
|
'numeric_code' => '<fg=yellow>Hàng tổng (numeric)</>',
|
|
'header_row' => '<fg=cyan>Hàng tiêu đề</>',
|
|
'empty_customer' => '<fg=magenta>Trống tên KH</>',
|
|
];
|
|
|
|
$reasonColors = [
|
|
'empty_code' => 'red',
|
|
'numeric_code' => 'yellow',
|
|
'header_row' => 'cyan',
|
|
'empty_customer' => 'magenta',
|
|
];
|
|
|
|
$grouped = [];
|
|
foreach ($this->skippedRowDetails as $detail) {
|
|
$grouped[$detail['reason']][] = $detail;
|
|
}
|
|
|
|
foreach ($grouped as $reason => $details) {
|
|
$color = $reasonColors[$reason] ?? 'white';
|
|
$label = $reasonLabels[$reason] ?? $reason;
|
|
$count = count($details);
|
|
|
|
$this->line(" <fg={$color};options=bold>{$label}</> — {$count} dòng:");
|
|
|
|
$showDetails = array_slice($details, 0, 30);
|
|
$rowNumbers = array_column($showDetails, 'row');
|
|
$codes = array_column($showDetails, 'code');
|
|
|
|
$chunks = array_chunk($rowNumbers, 15);
|
|
foreach ($chunks as $chunk) {
|
|
$this->line(" <fg={$color}>Rows: " . implode(', ', $chunk) . "</>");
|
|
}
|
|
|
|
if ($count > 30) {
|
|
$this->line(" <fg=gray>... và " . ($count - 30) . " dòng nữa</>");
|
|
}
|
|
|
|
$this->newLine();
|
|
}
|
|
|
|
$this->line('<fg=gray>Gợi ý: Các dòng "Empty customer name" thường là dòng trống trong Excel chỉ có mã căn hộ nhưng không có dữ liệu khác.</>');
|
|
$this->line('<fg=gray>Chúng được bỏ qua vì thiếu thông tin bắt buộc (Họ tên KH).</>');
|
|
}
|
|
}
|
|
}
|