From f13196780eca7cb5ad7214619acd92d6714e9087 Mon Sep 17 00:00:00 2001 From: sungro815 Date: Thu, 12 Feb 2026 13:48:48 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8/=EA=B0=80=EC=9E=85?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=ED=94=BC=20=ED=95=84=ED=84=B0=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Marketing/BuildMemberMarketingStats.php | 36 ++ .../AdminMemberJoinFilterController.php | 144 +++++ .../AdminMemberMarketingController.php | 66 ++ .../AdminMemberJoinFilterRepository.php | 68 ++ .../Member/AdminMemberMarketingRepository.php | 242 +++++++ .../Member/MemberMarketingBatchRepository.php | 221 +++++++ app/Services/Admin/AdminAuthService.php | 3 + .../Member/AdminMemberJoinFilterService.php | 282 ++++++++ .../Member/AdminMemberMarketingService.php | 361 +++++++++++ .../Member/MemberMarketingBatchService.php | 58 ++ .../members/join_filters/index.blade.php | 600 ++++++++++++++++++ .../views/admin/members/marketing.blade.php | 424 +++++++++++++ .../views/admin/partials/sidebar.blade.php | 6 +- routes/admin.php | 41 ++ routes/console.php | 6 + 15 files changed, 2554 insertions(+), 4 deletions(-) create mode 100644 app/Console/Commands/Marketing/BuildMemberMarketingStats.php create mode 100644 app/Http/Controllers/Admin/Members/AdminMemberJoinFilterController.php create mode 100644 app/Http/Controllers/Admin/Members/AdminMemberMarketingController.php create mode 100644 app/Repositories/Admin/Member/AdminMemberJoinFilterRepository.php create mode 100644 app/Repositories/Admin/Member/AdminMemberMarketingRepository.php create mode 100644 app/Repositories/Admin/Member/MemberMarketingBatchRepository.php create mode 100644 app/Services/Admin/Member/AdminMemberJoinFilterService.php create mode 100644 app/Services/Admin/Member/AdminMemberMarketingService.php create mode 100644 app/Services/Admin/Member/MemberMarketingBatchService.php create mode 100644 resources/views/admin/members/join_filters/index.blade.php create mode 100644 resources/views/admin/members/marketing.blade.php diff --git a/app/Console/Commands/Marketing/BuildMemberMarketingStats.php b/app/Console/Commands/Marketing/BuildMemberMarketingStats.php new file mode 100644 index 0000000..b915301 --- /dev/null +++ b/app/Console/Commands/Marketing/BuildMemberMarketingStats.php @@ -0,0 +1,36 @@ +option('date') ?? ''); + $asOf = $opt !== '' + ? Carbon::parse($opt)->toDateString() + : now()->subDay()->toDateString(); + + // no-lock 옵션은 Service쪽에서 lock을 무시하려면 구조를 바꿔야 하는데, + // 운영에서는 필요 없고, 테스트는 스케줄 없이 직접 커맨드 실행하면 되니 일단 유지. + $res = $service->run($asOf); + + if (($res['skipped'] ?? false) === true) { + $this->warn("SKIPPED (locked): {$asOf}"); + return self::SUCCESS; + } + + $this->info("DONE {$asOf} | daily={$res['daily_rows']} total={$res['total_rows']} stats={$res['stats_rows']}"); + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/Members/AdminMemberJoinFilterController.php b/app/Http/Controllers/Admin/Members/AdminMemberJoinFilterController.php new file mode 100644 index 0000000..ee7c9b6 --- /dev/null +++ b/app/Http/Controllers/Admin/Members/AdminMemberJoinFilterController.php @@ -0,0 +1,144 @@ +service->indexData($request->query()); + + return view('admin.members.join_filters.index', $data); + } + + /** + * AJAX row + */ + public function get(int $seq, Request $request) + { + $row = $this->service->getRow($seq); + + if (!$row) { + return response()->json([ + 'ok' => false, + 'message' => 'NOT_FOUND', + 'row' => null, + ], 404); + } + + return response()->json([ + 'ok' => true, + 'row' => $row, + ]); + } + + private function safeReturnTo(?string $rt): ?string + { + $rt = trim((string)$rt); + if ($rt === '') return null; + + // 상대경로만 허용 (오픈리다이렉트 방지) + if (!str_starts_with($rt, '/')) return null; + + // join-filters 화면으로만 복귀 허용 + if (!str_contains($rt, 'join-filters')) return null; + + return $rt; + } + + public function store(Request $request) + { + $payload = $this->validated($request); + + $res = $this->service->store( + input: $payload, + actorName: $this->actorName(), + ); + + $to = $this->safeReturnTo($request->input('_return_to')) + ?? route('admin.join-filters.index', [], false); + + return redirect()->to($to) + ->with('toast', [ + 'type' => ($res['ok'] ?? false) ? 'success' : 'danger', + 'title' => ($res['ok'] ?? false) ? '등록 완료' : '등록 실패', + 'message' => $res['message'] ?? '', + ]); + } + + public function update(int $seq, Request $request) + { + $payload = $this->validated($request); + + $res = $this->service->update( + seq: $seq, + input: $payload, + actorName: $this->actorName(), + ); + + $to = $this->safeReturnTo($request->input('_return_to')) + ?? route('admin.join-filters.index', [], false); + + return redirect()->to($to) + ->with('toast', [ + 'type' => ($res['ok'] ?? false) ? 'success' : 'danger', + 'title' => ($res['ok'] ?? false) ? '수정 완료' : '수정 실패', + 'message' => $res['message'] ?? '', + ]); + } + + public function destroy(int $seq, Request $request) + { + $res = $this->service->destroy($seq); + $to = $this->safeReturnTo($request->input('_return_to')) + ?? route('admin.join-filters.index', [], false); + + return redirect()->to($to) + ->with('toast', [ + 'type' => ($res['ok'] ?? false) ? 'success' : 'danger', + 'title' => ($res['ok'] ?? false) ? '삭제 완료' : '삭제 실패', + 'message' => $res['message'] ?? '', + ]); + } + + private function actorName(): string + { + $name = trim((string) session('admin_ctx.name')); + if ($name !== '') return $name; + + $u = auth('admin')->user(); + return trim((string)($u->name ?? $u->nickname ?? $u->email ?? '')); + } + + private function validated(Request $request): array + { + $data = $request->validate([ + 'gubun_code' => ['required', 'string', 'in:01,02'], + 'filter' => ['required', 'string', 'max:50'], + 'join_block' => ['required', 'string', 'in:S,A,N'], + + 'admin_phone' => ['nullable', 'array'], + 'admin_phone.*' => ['string', 'max:30'], + + // ✅ 모달에서 유지용(검증대상 아님) - withInput을 위해 받아둠 + 'filter_seq' => ['nullable', 'string', 'max:20'], + ]); + + if (($data['join_block'] ?? '') === 'S' && empty($data['admin_phone'])) { + return redirect()->back()->withInput()->withErrors([ + 'admin_phone' => '관리자SMS알림(S)을 선택한 경우 알림받을 관리자를 선택해야 합니다.', + ])->throwResponse(); + } + + return $data; + } +} diff --git a/app/Http/Controllers/Admin/Members/AdminMemberMarketingController.php b/app/Http/Controllers/Admin/Members/AdminMemberMarketingController.php new file mode 100644 index 0000000..8ec9497 --- /dev/null +++ b/app/Http/Controllers/Admin/Members/AdminMemberMarketingController.php @@ -0,0 +1,66 @@ +service->index($request->all()); + return view('admin.members.marketing', $data); + } + + public function export(Request $request) + { + $data = $request->validate([ + 'zip_password' => ['required', 'string', 'min:4', 'max:64'], + + // filters + 'qf' => ['nullable','string','in:all,mem_no,name,email,phone'], + 'q' => ['nullable','string','max:100'], + + 'stat_3' => ['nullable','string','in:1,2,3,4,5,6'], + + 'reg_from' => ['nullable','date'], + 'reg_to' => ['nullable','date'], + + 'no_login' => ['nullable','string','in:0,1'], + 'inactive_days_gte' => ['nullable','integer','min:0','max:36500'], + + 'has_purchase' => ['nullable','string','in:all,1,0'], + 'recent_purchase' => ['nullable','string','in:all,30,90'], + 'min_purchase_count' => ['nullable','integer','min:0','max:999999'], + 'min_purchase_amount' => ['nullable','integer','min:0','max:999999999999'], + + // 추가 옵션(접기 영역) + 'optin_sms' => ['nullable','string','in:all,1,0'], + 'optin_email' => ['nullable','string','in:all,1,0'], + 'has_phone' => ['nullable','string','in:all,1,0'], + 'has_email' => ['nullable','string','in:all,1,0'], + ]); + + $zipPassword = (string) $data['zip_password']; + unset($data['zip_password']); + + $res = $this->service->exportZip($data, $zipPassword); + + if (!($res['ok'] ?? false)) { + return redirect()->back()->with('toast', [ + 'type' => 'danger', + 'title' => '다운로드 실패', + 'message' => $res['message'] ?? '파일 생성에 실패했습니다.', + ]); + } + + return response() + ->download($res['zip_path'], $res['download_name']) + ->deleteFileAfterSend(true); + } +} diff --git a/app/Repositories/Admin/Member/AdminMemberJoinFilterRepository.php b/app/Repositories/Admin/Member/AdminMemberJoinFilterRepository.php new file mode 100644 index 0000000..f96baa7 --- /dev/null +++ b/app/Repositories/Admin/Member/AdminMemberJoinFilterRepository.php @@ -0,0 +1,68 @@ +where('gubun_code', $gubun); + + $jb = trim((string)($filters['join_block'] ?? '')); + if ($jb !== '') $q->where('join_block', $jb); + + $ip = trim((string)($filters['filter_ip'] ?? '')); + if ($ip !== '') $q->where('filter', $ip); + + $q->orderByDesc('dt_reg'); + + return $q->paginate($perPage)->withQueryString(); + } + + public function find(int $seq): ?array + { + $row = DB::table(self::TABLE)->where('seq', $seq)->first(); + return $row ? (array)$row : null; + } + + public function lockForUpdate(int $seq): ?array + { + $row = DB::table(self::TABLE)->where('seq', $seq)->lockForUpdate()->first(); + return $row ? (array)$row : null; + } + + public function insert(array $data): int + { + return (int) DB::table(self::TABLE)->insertGetId($data); + } + + // ✅ bool 말고 affected rows + public function update(int $seq, array $data): int + { + return (int) DB::table(self::TABLE)->where('seq', $seq)->update($data); + } + + public function delete(int $seq): int + { + return (int) DB::table(self::TABLE)->where('seq', $seq)->delete(); + } + + public function listActiveAdminsForSms(): array + { + return DB::table('admin_users') + ->select(['id', 'email', 'name', 'phone_enc']) + ->where('status', 'active') + ->orderBy('name', 'asc') + ->get() + ->map(fn($r) => (array)$r) + ->all(); + } +} diff --git a/app/Repositories/Admin/Member/AdminMemberMarketingRepository.php b/app/Repositories/Admin/Member/AdminMemberMarketingRepository.php new file mode 100644 index 0000000..59ced20 --- /dev/null +++ b/app/Repositories/Admin/Member/AdminMemberMarketingRepository.php @@ -0,0 +1,242 @@ +selectRaw('MAX(as_of_date) as d')->first(); + return $row?->d ? (string)$row->d : null; + } + + public function countMatches(array $filters): int + { + return (int) $this->baseQuery($filters)->count(); + } + + public function fetchPreview(array $filters, int $limit = 10): array + { + return $this->baseQuery($filters) + ->orderByDesc('ms.mem_no') + ->limit($limit) + ->get($this->selectCols()) + ->map(fn($r)=>(array)$r) + ->all(); + } + + public function chunkExport(array $filters, int $chunkSize, callable $cb): void + { + $q = $this->baseQuery($filters)->orderByDesc('ms.mem_no'); + + $last = null; + while (true) { + $qq = clone $q; + if ($last !== null) $qq->where('ms.mem_no', '<', $last); + + $rows = $qq->limit($chunkSize)->get($this->selectCols()); + if ($rows->isEmpty()) break; + + $arr = $rows->map(fn($r)=>(array)$r)->all(); + $cb($arr); + + $last = (int) end($arr)['mem_no']; + } + } + + private function selectCols(): array + { + return [ + 'ms.mem_no', + 'ms.as_of_date', + + 'ms.stat_3', + 'ms.reg_at', + 'ms.last_login_at', + 'ms.has_login', + 'ms.days_since_reg', + 'ms.days_since_login', + + 'ms.has_email', + 'ms.has_phone', + 'ms.optin_email', + 'ms.optin_sms', + 'ms.optin_push', + + 'ms.first_purchase_at', + 'ms.last_purchase_at', + 'ms.purchase_count_total', + 'ms.purchase_amount_total', + 'ms.purchase_count_30d', + 'ms.purchase_amount_30d', + 'ms.purchase_count_90d', + 'ms.purchase_amount_90d', + + // mem_info (다운로드/표시용) + 'mi.name', + 'mi.email', + 'mi.cell_phone', + ]; + } + + private function baseQuery(array $filters) + { + $q = DB::table('mem_marketing_stats as ms') + ->join('mem_info as mi', 'mi.mem_no', '=', 'ms.mem_no'); + + // ✅ 스냅샷(기준일) 고정: 기본은 최신 + $asOf = trim((string)($filters['as_of_date'] ?? '')); + if ($asOf === '') $asOf = $this->getAsOfDate() ?: ''; + if ($asOf !== '') $q->where('ms.as_of_date', $asOf); + + // ----------------------------- + // 키워드 검색 + // ----------------------------- + $qf = (string)($filters['qf'] ?? 'all'); // all|mem_no|name|email|phone + $keyword = trim((string)($filters['q'] ?? '')); + $phoneEnc = (string)($filters['phone_enc'] ?? ''); + + $digits = preg_replace('/\D+/', '', $keyword) ?? ''; + $isPhoneDigits = (bool) preg_match('/^\d{10,11}$/', $digits); + + if ($keyword !== '') { + switch ($qf) { + case 'mem_no': + if ($digits !== '' && ctype_digit($digits)) { + $q->where('ms.mem_no', (int)$digits); + } else { + $q->whereRaw('1=0'); + } + break; + + case 'name': + $q->where('mi.name', 'like', "%{$keyword}%"); + break; + + case 'email': + $q->where('mi.email', 'like', "%{$keyword}%"); + break; + + case 'phone': + if ($isPhoneDigits || $phoneEnc !== '') { + $q->where(function ($w) use ($digits, $phoneEnc, $isPhoneDigits) { + // mem_info.cell_phone 이 평문/암호문 섞여 있을 수 있어 둘 다 비교 + if ($isPhoneDigits) $w->orWhere('mi.cell_phone', $digits); + if ($phoneEnc !== '') $w->orWhere('mi.cell_phone', $phoneEnc); + }); + } else { + $q->whereRaw('1=0'); + } + break; + + default: // all + $q->where(function ($w) use ($keyword, $digits, $isPhoneDigits, $phoneEnc) { + if ($digits !== '' && ctype_digit($digits)) { + $w->orWhere('ms.mem_no', (int)$digits); + } + + $w->orWhere('mi.name', 'like', "%{$keyword}%") + ->orWhere('mi.email', 'like', "%{$keyword}%"); + + if ($isPhoneDigits) $w->orWhere('mi.cell_phone', $digits); + if ($phoneEnc !== '') $w->orWhere('mi.cell_phone', $phoneEnc); + }); + break; + } + } + + // ----------------------------- + // stat_3 + // ----------------------------- + $stat3 = trim((string)($filters['stat_3'] ?? '')); + if ($stat3 !== '') { + $q->where('ms.stat_3', (int)$stat3); + } + + // ----------------------------- + // 가입일 범위 (달력) + // ----------------------------- + $regFrom = trim((string)($filters['reg_from'] ?? '')); + if ($regFrom !== '') { + $q->where('ms.reg_at', '>=', $regFrom.' 00:00:00'); + } + + $regTo = trim((string)($filters['reg_to'] ?? '')); + if ($regTo !== '') { + $q->where('ms.reg_at', '<=', $regTo.' 23:59:59'); + } + + // ----------------------------- + // 로그인 기록 없음 / 미접속일수 + // ----------------------------- + $noLogin = (string)($filters['no_login'] ?? '0'); + if ($noLogin === '1') { + $q->where('ms.has_login', 0); + } + + $inactive = $filters['inactive_days_gte'] ?? null; + if ($inactive !== null && $inactive !== '') { + $n = max(0, (int)$inactive); + + $q->where(function ($w) use ($n) { + // 1) 로그인 기록 있는 회원: 마지막 로그인 기준 미접속 + $w->where('ms.days_since_login', '>=', $n) + + // 2) 로그인 기록 없는 회원: 가입일 기준으로 "미접속" 취급 + ->orWhere(function ($w2) use ($n) { + $w2->where('ms.has_login', 0) + ->where('ms.days_since_reg', '>=', $n); + }); + }); + } + + $recentLogin = $filters['recent_login_days_lte'] ?? null; + if ($recentLogin !== null && $recentLogin !== '') { + $n = max(0, (int)$recentLogin); + + $q->where('ms.has_login', 1) + ->whereNotNull('ms.days_since_login') + ->where('ms.days_since_login', '<=', $n); + } + + // ----------------------------- + // 수신동의 / 정보유무 + // ----------------------------- + $optSms = (string)($filters['optin_sms'] ?? 'all'); + if ($optSms === '1') $q->where('ms.optin_sms', 1); + if ($optSms === '0') $q->where('ms.optin_sms', 0); + + $optEmail = (string)($filters['optin_email'] ?? 'all'); + if ($optEmail === '1') $q->where('ms.optin_email', 1); + if ($optEmail === '0') $q->where('ms.optin_email', 0); + + $hasPhone = (string)($filters['has_phone'] ?? 'all'); + if ($hasPhone === '1') $q->where('ms.has_phone', 1); + if ($hasPhone === '0') $q->where('ms.has_phone', 0); + + $hasEmail = (string)($filters['has_email'] ?? 'all'); + if ($hasEmail === '1') $q->where('ms.has_email', 1); + if ($hasEmail === '0') $q->where('ms.has_email', 0); + + // ----------------------------- + // 구매조건 + // ----------------------------- + $hasPurchase = (string)($filters['has_purchase'] ?? 'all'); + if ($hasPurchase === '1') $q->where('ms.purchase_count_total', '>', 0); + if ($hasPurchase === '0') $q->where('ms.purchase_count_total', '=', 0); + + $recent = (string)($filters['recent_purchase'] ?? 'all'); + if ($recent === '30') $q->where('ms.purchase_count_30d', '>', 0); + if ($recent === '90') $q->where('ms.purchase_count_90d', '>', 0); + + $minCnt = $filters['min_purchase_count'] ?? null; + if ($minCnt !== null && $minCnt !== '') $q->where('ms.purchase_count_total', '>=', (int)$minCnt); + + $minAmt = $filters['min_purchase_amount'] ?? null; + if ($minAmt !== null && $minAmt !== '') $q->where('ms.purchase_amount_total', '>=', (int)$minAmt); + + return $q; + } +} diff --git a/app/Repositories/Admin/Member/MemberMarketingBatchRepository.php b/app/Repositories/Admin/Member/MemberMarketingBatchRepository.php new file mode 100644 index 0000000..e4cbaab --- /dev/null +++ b/app/Repositories/Admin/Member/MemberMarketingBatchRepository.php @@ -0,0 +1,221 @@ +where('ymd', $asOfDate)->count(); + } + + public function rebuildPurchaseTotal(string $asOfDate): int + { + DB::statement('DROP TABLE IF EXISTS mem_marketing_purchase_total_tmp'); + DB::statement('CREATE TABLE mem_marketing_purchase_total_tmp LIKE mem_marketing_purchase_total'); + + $sql = <<count(); + } + + public function rebuildStats(string $asOfDate): int + { + DB::statement('DROP TABLE IF EXISTS mem_marketing_stats_tmp'); + DB::statement('CREATE TABLE mem_marketing_stats_tmp LIKE mem_marketing_stats'); + + $sql = <<count(); + } + + public function upsertBatchRunStart(string $asOfDate): void + { + DB::statement( + "INSERT INTO marketing_batch_runs (as_of_date, status, daily_rows, total_rows, stats_rows, error_message, started_at, finished_at) + VALUES (?, 'running', 0, 0, 0, NULL, NOW(), NULL) + ON DUPLICATE KEY UPDATE status='running', error_message=NULL, started_at=VALUES(started_at), finished_at=NULL", + [$asOfDate] + ); + } + + public function markBatchRunSuccess(string $asOfDate, int $dailyRows, int $totalRows, int $statsRows): void + { + DB::statement( + "UPDATE marketing_batch_runs + SET status='success', daily_rows=?, total_rows=?, stats_rows=?, finished_at=NOW() + WHERE as_of_date=?", + [$dailyRows, $totalRows, $statsRows, $asOfDate] + ); + } + + public function markBatchRunFailed(string $asOfDate, string $err): void + { + DB::statement( + "UPDATE marketing_batch_runs + SET status='failed', error_message=?, finished_at=NOW() + WHERE as_of_date=?", + [$err, $asOfDate] + ); + } +} diff --git a/app/Services/Admin/AdminAuthService.php b/app/Services/Admin/AdminAuthService.php index dca0dfe..2c36e51 100644 --- a/app/Services/Admin/AdminAuthService.php +++ b/app/Services/Admin/AdminAuthService.php @@ -584,6 +584,9 @@ final class AdminAuthService return [ 'id' => (int)$admin->id, 'email' => (string)($admin->email ?? ''), + 'name' => (string)($admin->name ?? ''), + 'nickname' => (string)($admin->nickname ?? ''), + 'phone_enc' => (string)($admin->phone_enc ?? ''), 'roles' => $roles, 'role_ids' => $roleIds, 'role_names' => $roleNames, diff --git a/app/Services/Admin/Member/AdminMemberJoinFilterService.php b/app/Services/Admin/Member/AdminMemberJoinFilterService.php new file mode 100644 index 0000000..bcf4dba --- /dev/null +++ b/app/Services/Admin/Member/AdminMemberJoinFilterService.php @@ -0,0 +1,282 @@ + (string)($query['gubun_code'] ?? request()->query('gubun_code', '')), + 'join_block' => (string)($query['join_block'] ?? request()->query('join_block', '')), + 'filter_ip' => trim((string)($query['filter_ip'] ?? request()->query('filter_ip', ''))), + ]; + + if ($filters['filter_ip'] !== '' && !preg_match('/^[0-9.]+$/', $filters['filter_ip'])) { + $filters['filter_ip'] = ''; + } + + $page = $this->repo->paginateFilters($filters, 20); + + $rows = []; + foreach ($page->items() as $item) { + $r = is_array($item) ? $item : (array)$item; + + $arr = json_decode((string)($r['admin_phone'] ?? '[]'), true); + $r['admin_phone_arr'] = is_array($arr) ? $arr : []; + + $rows[] = $r; + } + + $adminsRaw = $this->repo->listActiveAdminsForSms(); + $admins = []; + + foreach ($adminsRaw as $a) { + $id = (int)($a['id'] ?? 0); + $enc = trim((string)($a['phone_enc'] ?? '')); + + $digits = ($enc !== '') ? $this->decryptPhoneEncToDigits($enc, $id) : ''; + $plain010 = ($digits !== '') ? $this->normalizeKrPhone($digits) : ''; + $value = ($plain010 !== '') ? $plain010 : $digits; + + $admins[] = [ + 'id' => $id, + 'email' => (string)($a['email'] ?? ''), + 'name' => (string)($a['name'] ?? ''), + 'phone_plain' => $value, + 'phone_display' => ($plain010 !== '' ? $this->formatPhone($plain010) : ($digits !== '' ? $digits : '')), + 'has_phone' => ($value !== ''), + ]; + } + + return [ + 'page' => $page, + 'rows' => $rows, + 'filters' => $filters, + + 'joinBlockCode' => $this->joinBlockCode(), + 'joinBlockStyle' => $this->joinBlockStyle(), + 'gubunCodeMap' => $this->gubunCodeMap(), + + 'admins' => $admins, + ]; + } + + public function getRow(int $seq): ?array + { + $row = $this->repo->find($seq); + if (!$row) return null; + + $arr = json_decode((string)($row['admin_phone'] ?? '[]'), true); + $row['admin_phone_arr'] = is_array($arr) ? $arr : []; + + return $row; + } + + public function store(array $input, string $actorName): array + { + try { + return DB::transaction(function () use ($input, $actorName) { + $data = $this->buildSaveData($input, $actorName); + + $seq = $this->repo->insert($data); + if ($seq <= 0) return $this->fail('등록에 실패했습니다.'); + + return $this->ok('정상적으로 등록되었습니다.', ['seq' => $seq]); + }); + } catch (\Throwable $e) { + Log::error('[join-filter] store failed', [ + 'err' => $e->getMessage(), + ]); + + // ✅ 유효성/형식 에러는 메시지 그대로 노출(관리자 페이지라 OK) + $msg = ($e instanceof \RuntimeException) ? $e->getMessage() : '등록 중 오류가 발생했습니다.'; + return $this->fail($msg); + } + } + + public function update(int $seq, array $input, string $actorName): array + { + try { + return DB::transaction(function () use ($seq, $input, $actorName) { + $before = $this->repo->lockForUpdate($seq); + if (!$before) return $this->fail('대상을 찾을 수 없습니다.'); + + $data = $this->buildSaveData($input, $actorName); + + $affected = $this->repo->update($seq, $data); + + // ✅ 0건(변경 없음)도 성공 처리 + if ($affected === 0) return $this->ok('변경사항이 없습니다. (저장 완료)'); + if ($affected > 0) return $this->ok('정상적으로 수정되었습니다.'); + + return $this->fail('수정에 실패했습니다.'); + }); + } catch (\Throwable $e) { + Log::error('[join-filter] update failed', [ + 'seq' => $seq, + 'err' => $e->getMessage(), + ]); + + $msg = ($e instanceof \RuntimeException) ? $e->getMessage() : '수정 중 오류가 발생했습니다.'; + return $this->fail($msg); + } + } + + public function destroy(int $seq): array + { + try { + return DB::transaction(function () use ($seq) { + $before = $this->repo->lockForUpdate($seq); + if (!$before) return $this->fail('대상을 찾을 수 없습니다.'); + + $affected = $this->repo->delete($seq); + if ($affected <= 0) return $this->fail('삭제에 실패했습니다.'); + + return $this->ok('정상적으로 삭제되었습니다.'); + }); + } catch (\Throwable $e) { + Log::error('[join-filter] destroy failed', [ + 'seq' => $seq, + 'err' => $e->getMessage(), + ]); + return $this->fail('삭제 중 오류가 발생했습니다.'); + } + } + + private function buildSaveData(array $input, string $actorName): array + { + $gubunCode = (string)($input['gubun_code'] ?? ''); + if (!in_array($gubunCode, ['01', '02'], true)) $gubunCode = '01'; + + $gubun = $this->gubunCodeMap()[$gubunCode] ?? 'IP 필터'; + + $joinBlock = (string)($input['join_block'] ?? 'N'); + if (!in_array($joinBlock, ['S', 'A', 'N'], true)) $joinBlock = 'N'; + + $filter = trim((string)($input['filter'] ?? '')); + if ($filter === '' || !$this->validateIpByGubun($filter, $gubunCode)) { + throw new \RuntimeException('아이피 형식이 올바르지 않습니다. (C=3옥텟, D=4옥텟)'); + } + + $phones = $input['admin_phone'] ?? []; + if (!is_array($phones) || empty($phones)) { + $phones = ['SMS발송없음']; + } else { + $out = []; + foreach ($phones as $p) { + $d = preg_replace('/\D+/', '', (string)$p) ?: ''; + $n = $this->normalizeKrPhone($d); + $out[] = ($n !== '' ? $n : $d); + } + $out = array_values(array_unique(array_filter($out, fn($v)=>$v !== ''))); + $phones = !empty($out) ? $out : ['SMS발송없음']; + } + + return [ + 'gubun' => $gubun, + 'gubun_code' => $gubunCode, + 'filter' => $filter, + 'join_block' => $joinBlock, + 'admin_phone' => json_encode($phones, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'admin' => $actorName, + 'dt_reg' => now()->format('Y-m-d H:i:s'), + ]; + } + + private function validateIpByGubun(string $ip, string $gubunCode): bool + { + $ip = trim($ip); + if ($ip === '' || !preg_match('/^[0-9.]+$/', $ip)) return false; + + $parts = explode('.', $ip); + if ($gubunCode === '01' && count($parts) !== 3) return false; + if ($gubunCode === '02' && count($parts) !== 4) return false; + + foreach ($parts as $p) { + if ($p === '' || !ctype_digit($p)) return false; + $n = (int)$p; + if ($n < 0 || $n > 255) return false; + } + return true; + } + + private function gubunCodeMap(): array + { + return ['01' => 'IP C-class 필터', '02' => 'IP D-class 필터']; + } + + private function joinBlockCode(): array + { + return ['S' => '관리자SMS알림', 'A' => '회원가입차단', 'N' => '필터끔']; + } + + private function joinBlockStyle(): array + { + return ['S' => 'pill--warn', 'A' => 'pill--bad', 'N' => 'pill--muted']; + } + + private function decryptPhoneEncToDigits(string $enc, int $adminId = 0): string + { + $enc = (string)$enc; + if ($enc === '') return ''; + + try { + $raw = Crypt::decryptString($enc); + $digits = preg_replace('/\D+/', '', (string)$raw) ?: ''; + return preg_match('/^\d{9,15}$/', $digits) ? $digits : ''; + } catch (\Throwable $e1) { + try { + $raw = decrypt($enc, false); + $digits = preg_replace('/\D+/', '', (string)$raw) ?: ''; + return preg_match('/^\d{9,15}$/', $digits) ? $digits : ''; + } catch (\Throwable $e2) { + if (preg_match('/^\d{9,15}$/', $enc)) return $enc; + + Log::warning('[join-filter] admin phone decrypt failed', [ + 'admin_id' => $adminId, + 'enc_len' => strlen($enc), + 'enc_head' => substr($enc, 0, 16), + 'e1' => $e1->getMessage(), + 'e2' => $e2->getMessage(), + ]); + return ''; + } + } + } + + 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 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); + } + + private function ok(string $msg, array $extra = []): array + { + return array_merge(['ok' => true, 'message' => $msg], $extra); + } + + private function fail(string $msg): array + { + return ['ok' => false, 'message' => $msg]; + } +} diff --git a/app/Services/Admin/Member/AdminMemberMarketingService.php b/app/Services/Admin/Member/AdminMemberMarketingService.php new file mode 100644 index 0000000..f929d19 --- /dev/null +++ b/app/Services/Admin/Member/AdminMemberMarketingService.php @@ -0,0 +1,361 @@ +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); + } +} diff --git a/app/Services/Admin/Member/MemberMarketingBatchService.php b/app/Services/Admin/Member/MemberMarketingBatchService.php new file mode 100644 index 0000000..1b30472 --- /dev/null +++ b/app/Services/Admin/Member/MemberMarketingBatchService.php @@ -0,0 +1,58 @@ +get()) { + Log::warning('[marketing-batch] skipped by lock', ['as_of_date' => $asOfDate]); + return ['skipped' => true, 'as_of_date' => $asOfDate]; + } + + try { + $this->repo->upsertBatchRunStart($asOfDate); + + Log::info('[marketing-batch] step A upsertDaily start', ['as_of_date' => $asOfDate]); + $dailyRows = $this->repo->upsertDaily($asOfDate); + Log::info('[marketing-batch] step A upsertDaily done', ['daily_rows' => $dailyRows]); + + Log::info('[marketing-batch] step B rebuildPurchaseTotal start', ['as_of_date' => $asOfDate]); + $totalRows = $this->repo->rebuildPurchaseTotal($asOfDate); + Log::info('[marketing-batch] step B rebuildPurchaseTotal done', ['total_rows' => $totalRows]); + + Log::info('[marketing-batch] step C rebuildStats start', ['as_of_date' => $asOfDate]); + $statsRows = $this->repo->rebuildStats($asOfDate); + Log::info('[marketing-batch] step C rebuildStats done', ['stats_rows' => $statsRows]); + + $this->repo->markBatchRunSuccess($asOfDate, $dailyRows, $totalRows, $statsRows); + + return [ + 'skipped' => false, + 'as_of_date' => $asOfDate, + 'daily_rows' => $dailyRows, + 'total_rows' => $totalRows, + 'stats_rows' => $statsRows, + ]; + } catch (\Throwable $e) { + $this->repo->markBatchRunFailed($asOfDate, $e->getMessage()); + Log::error('[marketing-batch] failed', ['as_of_date' => $asOfDate, 'err' => $e->getMessage()]); + throw $e; + } finally { + optional($lock)->release(); + } + } +} diff --git a/resources/views/admin/members/join_filters/index.blade.php b/resources/views/admin/members/join_filters/index.blade.php new file mode 100644 index 0000000..9c138b0 --- /dev/null +++ b/resources/views/admin/members/join_filters/index.blade.php @@ -0,0 +1,600 @@ +{{-- resources/views/admin/members/join_filters/index.blade.php --}} +@extends('admin.layouts.app') + +@section('title', '회원가입 필터') +@section('page_title', '회원가입 필터') +@section('page_desc', '회원가입 IP 필터를 등록/수정/삭제합니다.') +@section('content_class', 'a-content--full') + +@push('head') + +@endpush + +@section('content') + @php + $gubunMap = $gubunCodeMap ?? []; + $jbMap = $joinBlockCode ?? []; + $jbStyle = $joinBlockStyle ?? ['S'=>'pill--warn','A'=>'pill--bad','N'=>'pill--muted']; + + $gSel = (string)($filters['gubun_code'] ?? ''); + $jSel = (string)($filters['join_block'] ?? ''); + $ip = (string)($filters['filter_ip'] ?? ''); + + // ✅ 중요: 절대 URL(https/http) 꼬임 방지용으로 relative route 사용 + $indexUrl = route('admin.join-filters.index', [], false); + $storeUrl = route('admin.join-filters.store', [], false); + $getTpl = route('admin.join-filters.get', ['seq' => '__SEQ__'], false); + $updateTpl = route('admin.join-filters.update', ['seq' => '__SEQ__'], false); + + // ✅ ParseError 방지: old payload는 PHP에서 먼저 만든다 + $oldPayload = [ + 'filter_seq' => old('filter_seq', ''), + 'gubun_code' => old('gubun_code', '01'), + 'filter' => old('filter', ''), + 'join_block' => old('join_block', 'S'), + 'admin_phone' => old('admin_phone', []), + ]; + @endphp + +
+
+
+
회원가입 필터
+
등록/수정은 모달에서 처리 · 수정은 AJAX로 row 로드
+
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + 초기화 +
+
+ + +
+
+
+ +
+
{{ $page->total() }}건 (20개씩)
+ +
+ + + + + + + + + + + + + + + + @forelse(($rows ?? []) as $r) + @php + $seq = (int)($r['seq'] ?? 0); + $jb = (string)($r['join_block'] ?? 'N'); + $phones = $r['admin_phone_arr'] ?? []; + + $styleVal = $jbStyle[$jb] ?? 'pill--muted'; + $styleCls = is_array($styleVal) ? (string)($styleVal['class'] ?? 'pill--muted') : (string)$styleVal; + + $trCls = ($jb === 'N') ? 'line_through' : ''; + @endphp + + + + + + + + + + + @empty + + @endforelse + +
SEQ필터구분 [코드]필터내용가입차단여부관리자전화번호등록자등록시간처리
{{ $seq }} + {{ $r['gubun'] ?? '-' }} + [{{ $r['gubun_code'] ?? '-' }}] + {{ $r['filter'] ?? '-' }} + + ● {{ $jbMap[$jb] ?? $jb }} + + + @php + $formatPhone = function ($v) { + $s = trim((string)$v); + if ($s === '' || $s === 'SMS발송없음') return ''; + + $d = preg_replace('/\D+/', '', $s); + + // 11자리(010xxxxxxxx / 011xxxxxxxx 등) + if (preg_match('/^\d{11}$/', $d)) { + return substr($d,0,3).'-'.substr($d,3,4).'-'.substr($d,7,4); + } + + // 10자리(구형 일부 케이스) + if (preg_match('/^\d{10}$/', $d)) { + return substr($d,0,3).'-'.substr($d,3,3).'-'.substr($d,6,4); + } + + // 그 외는 원문 유지 + return $s; + }; + + $phonesLine = collect($phones ?? []) + ->map($formatPhone) + ->filter(fn($x) => $x !== '') + ->implode(', '); + @endphp + + @if($phonesLine === '') + - + @else + {{ $phonesLine }} + @endif + {{ $r['admin'] ?? '-' }}{{ $r['dt_reg'] ?? '-' }} + + +
+ @csrf + + +
+
데이터가 없습니다.
+
+ +
+ {{ $page->onEachSide(1)->links('vendor.pagination.admin') }} +
+
+ + {{-- Modal --}} + + + +@endsection diff --git a/resources/views/admin/members/marketing.blade.php b/resources/views/admin/members/marketing.blade.php new file mode 100644 index 0000000..e76f2c3 --- /dev/null +++ b/resources/views/admin/members/marketing.blade.php @@ -0,0 +1,424 @@ +@extends('admin.layouts.app') + +@section('title', '마케팅 회원 추출') +@section('page_title', '마케팅 회원 추출') +@section('page_desc', '조건에 맞는 회원을 빠르게 조회하고, 전체를 ZIP(암호)로 내려받습니다. (화면은 상위 10건만 표시)') + +@push('head') + +@endpush + +@section('content') + @php + $f = $filters ?? []; + + $stat3 = (string)($f['stat_3'] ?? ''); + + $regFrom = (string)($f['reg_from'] ?? ''); + $regTo = (string)($f['reg_to'] ?? ''); + + // ✅ 로그인 필터: mode + days (둘 중 하나만) + $loginMode = (string)($f['login_mode'] ?? 'none'); // none|inactive|recent + $loginDays = (string)($f['login_days'] ?? ''); + + // 구매/최근구매 + $hasPur = (string)($f['has_purchase'] ?? 'all'); // all|1|0 + $recent = (string)($f['recent_purchase'] ?? 'all'); // all|30|90 + $minCnt = (string)($f['min_purchase_count'] ?? ''); + $minAmt = (string)($f['min_purchase_amount'] ?? ''); + + // 수신동의/정보유무 + $optSms = (string)($f['optin_sms'] ?? 'all'); // all|1|0 + $optEmail = (string)($f['optin_email'] ?? 'all'); // all|1|0 + $hasPhone = (string)($f['has_phone'] ?? 'all'); // all|1|0 + $hasEmail = (string)($f['has_email'] ?? 'all'); // all|1|0 + @endphp + +
+
+
조건 매칭 건수
+
{{ number_format((int)($total ?? 0)) }} 건
+
+
+
화면 표시
+
상위 10건만 미리보기
+
+
+
통계 기준일
+
{{ $as_of_date ?? '-' }}
+
+
+ +
+ {{-- 검색/필터 --}} +
+
+
검색/필터
+ + {{-- stat_3 --}} +
+ +
+ + + + + @foreach(($stat3Map ?? []) as $k=>$label) + + + + + @endforeach +
+
+ + {{-- 가입일 --}} +
+
+ + +
+
+ + +
+
+ + {{-- 로그인 필터 (둘 중 하나만) --}} +
+ + +
+ + + + + + + + + + + + +
+ + +
+ - 미접속일수(이상): 최근 로그인으로부터 N일 이상 지난 회원
+ - 최근 로그인(이내): 최근 로그인으로부터 N일 이내인 회원 (예: 30 → 최근 30일 내 로그인) +
+
+ + {{-- 구매 조건 --}} +
+ +
+ + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + +
+ +
+ 구매 상세 조건(선택) +
+
+ + +
+
+ + +
+
+
+
+ + {{-- 추가 옵션 --}} +
+ 추가 옵션 (수신동의/정보유무) + +
+ +
+ + + + + + + + + + + + +
+
+ +
+ +
+ + + + + + + + + + + + +
+
+ +
+ +
+ + + + + + + + + + + + +
+
+ +
+ +
+ + + + + + + + + + + + +
+
+
+ +
+ + 초기화 +
+ +
+ 안내: 화면은 상위 10건만 표시합니다. 다운로드는 조건에 매칭되는 전체 데이터가 포함됩니다. +
+
+
+ + {{-- 다운로드 --}} +
+
전체 다운로드 (ZIP 암호화)
+ +
+ - 다운로드 파일에는 성명/전화번호/이메일이 포함됩니다.
+ - ZIP 비밀번호는 서버에 저장하지 않습니다. 분실 주의 +
+ +
+ @csrf + + {{-- filters hidden --}} + @foreach(($filters ?? []) as $k=>$v) + @if(is_scalar($v) && $k !== 'phone_enc') + + @endif + @endforeach + +
+ + + @error('zip_password')
{{ $message }}
@enderror +
+ + +
+
+
+ + {{-- 미리보기 --}} +
+
+
미리보기 (상위 10건)
+
총 {{ number_format((int)($total ?? 0)) }}건 중 상위 10건
+
+ +
+ + + + + + + + + + + + + + + + + + + + @forelse(($rows ?? []) as $r) + @php + $no = (int)($r['mem_no'] ?? 0); + $inactiveDays = $r['days_since_login']; + $inactiveDays = ($inactiveDays === null ? '-' : (string)$inactiveDays); + @endphp + + + + + + + + + + + + + + + + @empty + + @endforelse + +
회원번호성명전화번호이메일회원상태가입일시최근로그인미접속일수누적구매횟수누적구매금액최근구매일SMS수신이메일수신
#{{ $no }}{{ $r['name'] ?? '-' }}{{ $r['phone_display'] ?? '-' }}{{ $r['email'] ?? '' }}{{ $r['stat_3_label'] ?? ($r['stat_3'] ?? '') }}{{ $r['reg_at'] ?? '-' }}{{ $r['last_login_at'] ?? '-' }}{{ $inactiveDays }}{{ number_format((int)($r['purchase_count_total'] ?? 0)) }}{{ number_format((int)($r['purchase_amount_total'] ?? 0)) }}{{ $r['last_purchase_at'] ?? '-' }}{{ ((int)($r['optin_sms'] ?? 0)===1) ? '수신동의' : '수신미동의' }}{{ ((int)($r['optin_email'] ?? 0)===1) ? '수신동의' : '수신미동의' }}
조건에 해당하는 데이터가 없습니다.
+
+
+ + @push('scripts') + + @endpush +@endsection diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index 9ebf0a5..6659184 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -18,10 +18,8 @@ 'title' => '회원/정책', 'items' => [ ['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']], - ['label' => '회원 데이터추출', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']], - ['label' => '로그인/가입 아이피 필터설정', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']], - ['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index','roles' => ['super_admin','support']], - ['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']], + ['label' => '회원 데이터추출', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']], + ['label' => '로그인/가입 아이피 필터설정', 'route' => 'admin.join-filters.index','roles' => ['super_admin','support']], ], ], [ diff --git a/routes/admin.php b/routes/admin.php index 42feffd..169aed3 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -12,6 +12,9 @@ use App\Http\Controllers\Admin\Mail\AdminMailTemplateController; use App\Http\Controllers\Admin\Notice\AdminNoticeController; use App\Http\Controllers\Admin\Qna\AdminQnaController; use App\Http\Controllers\Admin\Members\AdminMembersController; +use App\Http\Controllers\Admin\Members\AdminMemberMarketingController; +use App\Http\Controllers\Admin\Members\AdminMemberJoinFilterController; + use Illuminate\Support\Facades\Route; Route::middleware(['web'])->group(function () { @@ -201,6 +204,44 @@ Route::middleware(['web'])->group(function () { ->whereNumber('memNo') ->name('force_out'); }); + + Route::prefix('marketing') + ->name('admin.marketing.') + ->middleware('admin.role:super_admin,support') + ->group(function () { + Route::get('/', [AdminMemberMarketingController::class, 'index']) + ->name('index'); + + Route::post('/export', [AdminMemberMarketingController::class, 'export']) + ->name('export'); + + }); + + Route::prefix('join-filters') + ->name('admin.join-filters.') + ->middleware('admin.role:super_admin') + ->group(function () { + + Route::get('/', [AdminMemberJoinFilterController::class, 'index']) + ->name('index'); + + Route::get('/{seq}', [AdminMemberJoinFilterController::class, 'get']) + ->whereNumber('seq') + ->name('get'); // AJAX row + + Route::post('/', [AdminMemberJoinFilterController::class, 'store']) + ->name('store'); + + Route::match(['POST','PUT'], '/{seq}', [AdminMemberJoinFilterController::class, 'update']) + ->whereNumber('seq') + ->name('update'); + + Route::match(['POST','DELETE'], '/{seq}/delete', [AdminMemberJoinFilterController::class, 'destroy']) + ->whereNumber('seq') + ->name('destroy'); + }); + + }); }); diff --git a/routes/console.php b/routes/console.php index 9c8901f..d538bec 100644 --- a/routes/console.php +++ b/routes/console.php @@ -115,6 +115,12 @@ registerScheduleCron('admin_mail_dispatch_due', '* * * * *', 'admin-mail:dispatc 'run_in_background' => true, ]); +registerScheduleCron('marketing_members_stats_daily', '10 3 * * *', 'marketing:members:build-stats', [ + 'without_overlapping' => true, + 'on_one_server' => true, + 'run_in_background' => true, +]); +