Files
minicrm/app/Console/Commands/ImportCskh.php
phuongtc efa97b6c71
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
them log, chinh upload
2026-05-09 11:00:59 +00:00

568 lines
18 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Services\ActivityLogger;
use App\Models\Contract;
use App\Models\Customer;
use App\Models\CustomerProduct;
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';
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 isMetaRow(array $row): bool
{
return $this->getMetaRowReason($row) !== null;
}
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->ensureCustomerProduct($product, $customer);
$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 ensureCustomerProduct(Product $product, Customer $customer): void
{
CustomerProduct::firstOrCreate([
'customer_id' => $customer->id,
'product_id' => $product->id,
]);
}
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('type', 'lease')
->exists();
if (! $exists) {
Contract::create([
'product_id' => $product->id,
'customer_id' => $customer->id,
'type' => 'lease',
'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('type', 'bcc')
->exists();
if (! $exists) {
Contract::create([
'product_id' => $product->id,
'customer_id' => $customer->id,
'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ử';
$customerProduct = CustomerProduct::where('customer_id', $customer->id)
->where('product_id', $product->id)
->first();
$feedback = Feedback::firstOrCreate(
[
'customer_id' => $customer->id,
'customer_product_id' => $customerProduct?->id,
'title' => $title,
],
[
'content' => $content,
'status' => 'pending',
'contract_id' => $activeContract?->id,
'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++;
}
}
/**
* Parse the PHP DateTime JSON format: {"date":"2021-06-09 00:00:00.000000","timezone_type":3,"timezone":"UTC"}
*/
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).</>');
}
}
}