giftcon_dev/app/Services/Admin/Member/AdminMemberMarketingService.php
2026-03-03 15:13:16 +09:00

362 lines
13 KiB
PHP

<?php
namespace App\Services\Admin\Member;
use App\Repositories\Admin\Member\AdminMemberMarketingRepository;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Support\Facades\Log;
final class AdminMemberMarketingService
{
public function __construct(
private readonly AdminMemberMarketingRepository $repo,
) {}
public function index(array $filters): array
{
$filters = $this->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);
}
}