normalizeFilters($filters); $asOfDate = $this->repo->getAsOfDate(); $total = $this->repo->countMatches($filters); $rows = $this->repo->fetchPreview($filters, 10); $mode = (string)($filters['login_mode'] ?? 'none'); $days = trim((string)($filters['login_days'] ?? '')); unset($filters['inactive_days_gte'], $filters['recent_login_days_lte']); // 기존키 정리 if ($mode === 'inactive' && $days !== '') { $filters['inactive_days_gte'] = (int)$days; } elseif ($mode === 'recent' && $days !== '') { $filters['recent_login_days_lte'] = (int)$days; } else { // none or invalid $filters['login_mode'] = 'none'; $filters['login_days'] = ''; } foreach ($rows as &$r) { $plain = $this->plainPhone((string)($r['cell_phone'] ?? '')); $r['phone_plain'] = $plain; $r['phone_display'] = $this->formatPhone($plain); $s3 = (string)($r['stat_3'] ?? ''); $r['stat_3_label'] = $this->stat3Map()[$s3] ?? $s3; } unset($r); return [ 'filters' => $filters, 'asOfDate' => $asOfDate, 'total' => $total, 'rows' => $rows, 'stat3Map' => $this->stat3Map(), // 라디오용 'yesnoAll' => ['all'=>'전체', '1'=>'예', '0'=>'아니오'], 'agreeAll' => ['all'=>'전체', '1'=>'동의', '0'=>'미동의'], 'recentMap' => ['all'=>'전체', '30'=>'최근 30일 구매', '90'=>'최근 90일 구매'], 'qfMap' => [ 'all' => '통합', 'mem_no' => '회원번호', 'name' => '성명', 'email' => '이메일', 'phone' => '전화번호', ], ]; } public function exportZip(array $filters, string $zipPassword): array { $filters = $this->normalizeFilters($filters); $asOfDate = $this->repo->getAsOfDate() ?: now()->format('Y-m-d'); $ts = now()->format('Ymd_His'); $tmpDir = storage_path('app/tmp/marketing'); if (!is_dir($tmpDir)) @mkdir($tmpDir, 0775, true); $baseName = "marketing_members_{$asOfDate}_{$ts}"; $xlsxPath = "{$tmpDir}/{$baseName}.xlsx"; $csvPath = "{$tmpDir}/{$baseName}.csv"; $zipPath = "{$tmpDir}/{$baseName}.zip"; $useXlsx = class_exists(\PhpOffice\PhpSpreadsheet\Spreadsheet::class); try { if ($useXlsx) { $this->buildXlsx($filters, $xlsxPath); $this->makeEncryptedZip($zipPath, $zipPassword, $xlsxPath, basename($xlsxPath)); @unlink($xlsxPath); } else { $this->buildCsv($filters, $csvPath); $this->makeEncryptedZip($zipPath, $zipPassword, $csvPath, basename($csvPath)); @unlink($csvPath); } return [ 'ok' => true, 'zip_path' => $zipPath, 'download_name' => basename($zipPath), ]; } catch (\Throwable $e) { Log::error('[marketing-export] failed', ['err' => $e->getMessage()]); @unlink($xlsxPath); @unlink($csvPath); @unlink($zipPath); return ['ok' => false, 'message' => '파일 생성 중 오류가 발생했습니다. (서버 로그 확인)']; } } private function normalizeFilters(array $filters): array { $filters['qf'] = (string)($filters['qf'] ?? 'all'); $filters['q'] = trim((string)($filters['q'] ?? '')); if ($filters['q'] !== '') { $digits = preg_replace('/\D+/', '', (string)$filters['q']) ?? ''; if (preg_match('/^\d{10,11}$/', $digits)) { $phone = $this->normalizeKrPhone($digits); if ($phone !== '') $filters['phone_enc'] = $this->encryptPhone($phone); } } foreach (['optin_sms','optin_email','has_phone','has_email','has_purchase','recent_purchase'] as $k) { $filters[$k] = (string)($filters[$k] ?? 'all'); } $filters['stat_3'] = trim((string)($filters['stat_3'] ?? '')); $filters['reg_from'] = trim((string)($filters['reg_from'] ?? '')); $filters['reg_to'] = trim((string)($filters['reg_to'] ?? '')); $filters['no_login'] = (string)($filters['no_login'] ?? '0'); $filters['inactive_days_gte'] = ($filters['inactive_days_gte'] ?? '') !== '' ? (int)$filters['inactive_days_gte'] : null; $filters['min_purchase_count'] = ($filters['min_purchase_count'] ?? '') !== '' ? (int)$filters['min_purchase_count'] : null; $filters['min_purchase_amount'] = ($filters['min_purchase_amount'] ?? '') !== '' ? (int)$filters['min_purchase_amount'] : null; return $filters; } // ------------------------- // 다운로드 헤더(한글) // ------------------------- private function headersKorean(): array { return [ '회원번호', '성명', '전화번호', '이메일', '로그인상태', '가입일시', '최근로그인일시', '미접속일수', 'SMS수신동의', '이메일수신동의', '구매여부', '누적구매횟수', '누적구매금액', '최근30일구매횟수', '최근30일구매금액', '최근90일구매횟수', '최근90일구매금액', '최근구매일시', '통계기준일', ]; } private function buildCsv(array $filters, string $path): void { $fp = fopen($path, 'w'); if (!$fp) throw new \RuntimeException('cannot open csv'); // Excel 한글 깨짐 방지 BOM fwrite($fp, "\xEF\xBB\xBF"); fputcsv($fp, $this->headersKorean()); $seed = app(CiSeedCrypto::class); $statMap = $this->stat3Map(); $this->repo->chunkExport($filters, 2000, function (array $rows) use ($fp, $seed, $statMap) { foreach ($rows as $r) { $phonePlain = $this->plainPhone((string)($r['cell_phone'] ?? ''), $seed); $pcTotal = (int)($r['purchase_count_total'] ?? 0); $hasPurchase = $pcTotal > 0 ? '있음' : '없음'; $s3 = (string)($r['stat_3'] ?? ''); $s3Label = $statMap[$s3] ?? $s3; fputcsv($fp, [ (int)$r['mem_no'], (string)($r['name'] ?? ''), $phonePlain, // 마케팅 발송용: 숫자만 (string)($r['email'] ?? ''), $s3Label, (string)($r['reg_at'] ?? ''), (string)($r['last_login_at'] ?? ''), ($r['days_since_login'] === null ? '' : (int)$r['days_since_login']), ((int)($r['optin_sms'] ?? 0) === 1 ? '동의' : '미동의'), ((int)($r['optin_email'] ?? 0) === 1 ? '동의' : '미동의'), $hasPurchase, $pcTotal, (int)($r['purchase_amount_total'] ?? 0), (int)($r['purchase_count_30d'] ?? 0), (int)($r['purchase_amount_30d'] ?? 0), (int)($r['purchase_count_90d'] ?? 0), (int)($r['purchase_amount_90d'] ?? 0), (string)($r['last_purchase_at'] ?? ''), (string)($r['as_of_date'] ?? ''), ]); } }); fclose($fp); } private function buildXlsx(array $filters, string $path): void { $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('회원추출'); $headers = $this->headersKorean(); foreach ($headers as $i => $h) { $sheet->setCellValueByColumnAndRow($i + 1, 1, $h); } $rowNo = 2; $seed = app(CiSeedCrypto::class); $statMap = $this->stat3Map(); $this->repo->chunkExport($filters, 2000, function (array $rows) use ($sheet, &$rowNo, $seed, $statMap) { foreach ($rows as $r) { $phonePlain = $this->plainPhone((string)($r['cell_phone'] ?? ''), $seed); $pcTotal = (int)($r['purchase_count_total'] ?? 0); $hasPurchase = $pcTotal > 0 ? '있음' : '없음'; $s3 = (string)($r['stat_3'] ?? ''); $s3Label = $statMap[$s3] ?? $s3; $values = [ (int)$r['mem_no'], (string)($r['name'] ?? ''), $phonePlain, (string)($r['email'] ?? ''), $s3Label, (string)($r['reg_at'] ?? ''), (string)($r['last_login_at'] ?? ''), ($r['days_since_login'] === null ? '' : (int)$r['days_since_login']), ((int)($r['optin_sms'] ?? 0) === 1 ? '동의' : '미동의'), ((int)($r['optin_email'] ?? 0) === 1 ? '동의' : '미동의'), $hasPurchase, $pcTotal, (int)($r['purchase_amount_total'] ?? 0), (int)($r['purchase_count_30d'] ?? 0), (int)($r['purchase_amount_30d'] ?? 0), (int)($r['purchase_count_90d'] ?? 0), (int)($r['purchase_amount_90d'] ?? 0), (string)($r['last_purchase_at'] ?? ''), (string)($r['as_of_date'] ?? ''), ]; foreach ($values as $i => $v) { $sheet->setCellValueByColumnAndRow($i + 1, $rowNo, $v); } $rowNo++; } }); $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); $writer->save($path); } private function makeEncryptedZip(string $zipPath, string $password, string $filePath, string $innerName): void { if (!class_exists(\ZipArchive::class)) throw new \RuntimeException('ZipArchive not available'); $zip = new \ZipArchive(); if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { throw new \RuntimeException('cannot open zip'); } $zip->setPassword($password); $zip->addFile($filePath, $innerName); if (method_exists($zip, 'setEncryptionName')) { $method = defined('\ZipArchive::EM_AES_256') ? \ZipArchive::EM_AES_256 : \ZipArchive::EM_TRAD_PKWARE; $zip->setEncryptionName($innerName, $method, $password); } $zip->close(); } // ------------------------- // Maps / helpers // ------------------------- private function stat3Map(): array { return [ '1' => '로그인정상', /*'2' => '2. 로그인만가능', '3' => '3. 로그인불가 (접근금지)', '4' => '4. 탈퇴완료(아이디보관)', '5' => '5. 탈퇴완료',*/ '6' => '휴면회원', ]; } private function normalizeKrPhone(string $raw): string { $n = preg_replace('/\D+/', '', $raw) ?? ''; if ($n === '') return ''; if (str_starts_with($n, '82')) $n = '0'.substr($n, 2); if (!preg_match('/^01[016789]\d{7,8}$/', $n)) return ''; return $n; } private function encryptPhone(string $raw010): string { $seed = app(CiSeedCrypto::class); return (string) $seed->encrypt($raw010); } private function plainPhone(string $cellPhoneCol, ?CiSeedCrypto $seed = null): string { $v = trim($cellPhoneCol); if ($v === '') return ''; $digits = preg_replace('/\D+/', '', $v) ?? ''; if (preg_match('/^\d{10,11}$/', $digits)) return $digits; try { $seed = $seed ?: app(CiSeedCrypto::class); $plain = (string) $seed->decrypt($v); $plainDigits = preg_replace('/\D+/', '', $plain) ?? ''; if (preg_match('/^\d{10,11}$/', $plainDigits)) return $plainDigits; } catch (\Throwable $e) {} return ''; } private function formatPhone(string $digits): string { if (!preg_match('/^\d{10,11}$/', $digits)) return '-'; if (strlen($digits) === 11) return substr($digits,0,3).'-'.substr($digits,3,4).'-'.substr($digits,7,4); return substr($digits,0,3).'-'.substr($digits,3,3).'-'.substr($digits,6,4); } }