로그인/가입 아이피 필터 작업
This commit is contained in:
parent
f4ac32ae33
commit
f13196780e
36
app/Console/Commands/Marketing/BuildMemberMarketingStats.php
Normal file
36
app/Console/Commands/Marketing/BuildMemberMarketingStats.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Marketing;
|
||||||
|
|
||||||
|
use App\Services\Admin\Member\MemberMarketingBatchService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
final class BuildMemberMarketingStats extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'marketing:members:build-stats
|
||||||
|
{--date= : 기준일(YYYY-MM-DD). 비우면 어제}
|
||||||
|
{--no-lock : Redis lock 없이 실행(디버그용)}';
|
||||||
|
|
||||||
|
protected $description = '전일 기준 회원 마케팅 통계(mem_marketing_*) 생성/갱신';
|
||||||
|
|
||||||
|
public function handle(MemberMarketingBatchService $service): int
|
||||||
|
{
|
||||||
|
$opt = (string) ($this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Members;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Member\AdminMemberJoinFilterService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class AdminMemberJoinFilterController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminMemberJoinFilterService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
// ✅ query 전달(전역 request 의존 줄이기)
|
||||||
|
$data = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Members;
|
||||||
|
|
||||||
|
use App\Services\Admin\Member\AdminMemberMarketingService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class AdminMemberMarketingController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminMemberMarketingService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$data = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Member;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AdminMemberJoinFilterRepository
|
||||||
|
{
|
||||||
|
private const TABLE = 'mem_join_filter';
|
||||||
|
|
||||||
|
public function paginateFilters(array $filters, int $perPage = 20): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = DB::table(self::TABLE);
|
||||||
|
|
||||||
|
$gubun = trim((string)($filters['gubun_code'] ?? ''));
|
||||||
|
if ($gubun !== '') $q->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
242
app/Repositories/Admin/Member/AdminMemberMarketingRepository.php
Normal file
242
app/Repositories/Admin/Member/AdminMemberMarketingRepository.php
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Member;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AdminMemberMarketingRepository
|
||||||
|
{
|
||||||
|
public function getAsOfDate(): ?string
|
||||||
|
{
|
||||||
|
$row = DB::table('mem_marketing_stats')->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
221
app/Repositories/Admin/Member/MemberMarketingBatchRepository.php
Normal file
221
app/Repositories/Admin/Member/MemberMarketingBatchRepository.php
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Member;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class MemberMarketingBatchRepository
|
||||||
|
{
|
||||||
|
public function upsertDaily(string $asOfDate): int
|
||||||
|
{
|
||||||
|
$from = $asOfDate.' 00:00:00';
|
||||||
|
$to = $asOfDate.' 23:59:59';
|
||||||
|
|
||||||
|
$sql = <<<SQL
|
||||||
|
INSERT INTO mem_marketing_purchase_daily
|
||||||
|
(ymd, mem_no,
|
||||||
|
paid_count, paid_amount, paid_first_at, paid_last_at,
|
||||||
|
cancel_count, cancel_amount)
|
||||||
|
SELECT
|
||||||
|
? AS ymd,
|
||||||
|
x.mem_no,
|
||||||
|
SUM(x.paid_count) AS paid_count,
|
||||||
|
SUM(x.paid_amount) AS paid_amount,
|
||||||
|
MIN(x.paid_first_at) AS paid_first_at,
|
||||||
|
MAX(x.paid_last_at) AS paid_last_at,
|
||||||
|
SUM(x.cancel_count) AS cancel_count,
|
||||||
|
SUM(x.cancel_amount) AS cancel_amount
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
po.mem_no,
|
||||||
|
COUNT(*) AS paid_count,
|
||||||
|
SUM(po.pay_money) AS paid_amount,
|
||||||
|
MIN(po.dt_stat_pay_p) AS paid_first_at,
|
||||||
|
MAX(po.dt_stat_pay_p) AS paid_last_at,
|
||||||
|
0 AS cancel_count,
|
||||||
|
0 AS cancel_amount
|
||||||
|
FROM pin_order po
|
||||||
|
WHERE po.mem_no IS NOT NULL
|
||||||
|
AND po.stat_pay = 'p'
|
||||||
|
AND po.dt_stat_pay_p BETWEEN ? AND ?
|
||||||
|
GROUP BY po.mem_no
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
po.mem_no,
|
||||||
|
0 AS paid_count,
|
||||||
|
0 AS paid_amount,
|
||||||
|
NULL AS paid_first_at,
|
||||||
|
NULL AS paid_last_at,
|
||||||
|
COUNT(*) AS cancel_count,
|
||||||
|
SUM(po.pay_money) AS cancel_amount
|
||||||
|
FROM pin_order po
|
||||||
|
WHERE po.mem_no IS NOT NULL
|
||||||
|
AND po.stat_pay = 'c'
|
||||||
|
AND po.dt_stat_cancel BETWEEN ? AND ?
|
||||||
|
GROUP BY po.mem_no
|
||||||
|
) x
|
||||||
|
GROUP BY x.mem_no
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
paid_count = VALUES(paid_count),
|
||||||
|
paid_amount = VALUES(paid_amount),
|
||||||
|
paid_first_at = VALUES(paid_first_at),
|
||||||
|
paid_last_at = VALUES(paid_last_at),
|
||||||
|
cancel_count = VALUES(cancel_count),
|
||||||
|
cancel_amount = VALUES(cancel_amount);
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
// rowcount는 드라이버/설정에 따라 정확하지 않을 수 있어 “실제 건수”는 별도 count로 잡는다.
|
||||||
|
DB::statement($sql, [$asOfDate, $from, $to, $from, $to]);
|
||||||
|
|
||||||
|
return (int) DB::table('mem_marketing_purchase_daily')->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 = <<<SQL
|
||||||
|
INSERT INTO mem_marketing_purchase_total_tmp
|
||||||
|
(mem_no, first_purchase_at, last_purchase_at,
|
||||||
|
paid_count_total, paid_amount_total,
|
||||||
|
cancel_count_total, cancel_amount_total,
|
||||||
|
as_of_date)
|
||||||
|
SELECT
|
||||||
|
d.mem_no,
|
||||||
|
MIN(d.paid_first_at) AS first_purchase_at,
|
||||||
|
MAX(d.paid_last_at) AS last_purchase_at,
|
||||||
|
SUM(d.paid_count) AS paid_count_total,
|
||||||
|
SUM(d.paid_amount) AS paid_amount_total,
|
||||||
|
SUM(d.cancel_count) AS cancel_count_total,
|
||||||
|
SUM(d.cancel_amount) AS cancel_amount_total,
|
||||||
|
? AS as_of_date
|
||||||
|
FROM mem_marketing_purchase_daily d
|
||||||
|
GROUP BY d.mem_no;
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
DB::statement($sql, [$asOfDate]);
|
||||||
|
|
||||||
|
// 스왑
|
||||||
|
DB::statement('DROP TABLE IF EXISTS mem_marketing_purchase_total_old');
|
||||||
|
DB::statement('RENAME TABLE mem_marketing_purchase_total TO mem_marketing_purchase_total_old, mem_marketing_purchase_total_tmp TO mem_marketing_purchase_total');
|
||||||
|
DB::statement('DROP TABLE mem_marketing_purchase_total_old');
|
||||||
|
|
||||||
|
return (int) DB::table('mem_marketing_purchase_total')->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 = <<<SQL
|
||||||
|
INSERT INTO mem_marketing_stats_tmp (
|
||||||
|
mem_no,
|
||||||
|
stat_3, stat_3_at,
|
||||||
|
reg_at, last_login_at, has_login,
|
||||||
|
days_since_reg, days_since_login,
|
||||||
|
has_email, has_phone, optin_email, optin_sms, optin_push,
|
||||||
|
pv_sns, country_code,
|
||||||
|
first_purchase_at, last_purchase_at,
|
||||||
|
purchase_count_total, purchase_amount_total,
|
||||||
|
purchase_count_30d, purchase_amount_30d,
|
||||||
|
purchase_count_90d, purchase_amount_90d,
|
||||||
|
as_of_date
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
mi.mem_no,
|
||||||
|
CAST(mi.stat_3 AS UNSIGNED) AS stat_3,
|
||||||
|
NULLIF(mi.dt_stat_3, '0000-00-00 00:00:00') AS stat_3_at,
|
||||||
|
NULLIF(mi.dt_reg, '0000-00-00 00:00:00') AS reg_at,
|
||||||
|
NULLIF(mi.dt_login, '0000-00-00 00:00:00') AS last_login_at,
|
||||||
|
CASE WHEN NULLIF(mi.dt_login, '0000-00-00 00:00:00') IS NULL THEN 0 ELSE 1 END AS has_login,
|
||||||
|
CASE
|
||||||
|
WHEN NULLIF(mi.dt_reg, '0000-00-00 00:00:00') IS NULL THEN 0
|
||||||
|
ELSE GREATEST(DATEDIFF(?, DATE(NULLIF(mi.dt_reg, '0000-00-00 00:00:00'))), 0)
|
||||||
|
END AS days_since_reg,
|
||||||
|
CASE
|
||||||
|
WHEN NULLIF(mi.dt_login, '0000-00-00 00:00:00') IS NULL THEN NULL
|
||||||
|
ELSE GREATEST(DATEDIFF(?, DATE(NULLIF(mi.dt_login, '0000-00-00 00:00:00'))), 0)
|
||||||
|
END AS days_since_login,
|
||||||
|
CASE WHEN mi.email IS NULL OR mi.email='' THEN 0 ELSE 1 END AS has_email,
|
||||||
|
CASE WHEN mi.cell_phone IS NULL OR mi.cell_phone='' THEN 0 ELSE 1 END AS has_phone,
|
||||||
|
CASE WHEN mi.rcv_email='y' THEN 1 ELSE 0 END AS optin_email,
|
||||||
|
CASE WHEN mi.rcv_sms ='y' THEN 1 ELSE 0 END AS optin_sms,
|
||||||
|
CASE WHEN mi.rcv_push ='y' THEN 1 ELSE 0 END AS optin_push,
|
||||||
|
mi.pv_sns,
|
||||||
|
mi.country_code,
|
||||||
|
pt.first_purchase_at,
|
||||||
|
pt.last_purchase_at,
|
||||||
|
COALESCE(pt.paid_count_total, 0) AS purchase_count_total,
|
||||||
|
COALESCE(pt.paid_amount_total, 0) AS purchase_amount_total,
|
||||||
|
COALESCE(w30.c30, 0) AS purchase_count_30d,
|
||||||
|
COALESCE(w30.a30, 0) AS purchase_amount_30d,
|
||||||
|
COALESCE(w90.c90, 0) AS purchase_count_90d,
|
||||||
|
COALESCE(w90.a90, 0) AS purchase_amount_90d,
|
||||||
|
? AS as_of_date
|
||||||
|
FROM mem_info mi
|
||||||
|
LEFT JOIN mem_marketing_purchase_total pt
|
||||||
|
ON pt.mem_no = mi.mem_no
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT mem_no, SUM(paid_count) c30, SUM(paid_amount) a30
|
||||||
|
FROM mem_marketing_purchase_daily
|
||||||
|
WHERE ymd BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ?
|
||||||
|
GROUP BY mem_no
|
||||||
|
) w30 ON w30.mem_no = mi.mem_no
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT mem_no, SUM(paid_count) c90, SUM(paid_amount) a90
|
||||||
|
FROM mem_marketing_purchase_daily
|
||||||
|
WHERE ymd BETWEEN DATE_SUB(?, INTERVAL 89 DAY) AND ?
|
||||||
|
GROUP BY mem_no
|
||||||
|
) w90 ON w90.mem_no = mi.mem_no;
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
// placeholders: DATEDIFF(?,...), DATEDIFF(?,...), as_of_date, w30 range(?,?), w90 range(?,?)
|
||||||
|
DB::statement($sql, [
|
||||||
|
$asOfDate, $asOfDate,
|
||||||
|
$asOfDate,
|
||||||
|
$asOfDate, $asOfDate,
|
||||||
|
$asOfDate, $asOfDate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 스왑
|
||||||
|
DB::statement('DROP TABLE IF EXISTS mem_marketing_stats_old');
|
||||||
|
DB::statement('RENAME TABLE mem_marketing_stats TO mem_marketing_stats_old, mem_marketing_stats_tmp TO mem_marketing_stats');
|
||||||
|
DB::statement('DROP TABLE mem_marketing_stats_old');
|
||||||
|
|
||||||
|
return (int) DB::table('mem_marketing_stats')->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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -584,6 +584,9 @@ final class AdminAuthService
|
|||||||
return [
|
return [
|
||||||
'id' => (int)$admin->id,
|
'id' => (int)$admin->id,
|
||||||
'email' => (string)($admin->email ?? ''),
|
'email' => (string)($admin->email ?? ''),
|
||||||
|
'name' => (string)($admin->name ?? ''),
|
||||||
|
'nickname' => (string)($admin->nickname ?? ''),
|
||||||
|
'phone_enc' => (string)($admin->phone_enc ?? ''),
|
||||||
'roles' => $roles,
|
'roles' => $roles,
|
||||||
'role_ids' => $roleIds,
|
'role_ids' => $roleIds,
|
||||||
'role_names' => $roleNames,
|
'role_names' => $roleNames,
|
||||||
|
|||||||
282
app/Services/Admin/Member/AdminMemberJoinFilterService.php
Normal file
282
app/Services/Admin/Member/AdminMemberJoinFilterService.php
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Member;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Member\AdminMemberJoinFilterRepository;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
final class AdminMemberJoinFilterService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminMemberJoinFilterRepository $repo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function indexData(array $query = []): array
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'gubun_code' => (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];
|
||||||
|
}
|
||||||
|
}
|
||||||
361
app/Services/Admin/Member/AdminMemberMarketingService.php
Normal file
361
app/Services/Admin/Member/AdminMemberMarketingService.php
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Services/Admin/Member/MemberMarketingBatchService.php
Normal file
58
app/Services/Admin/Member/MemberMarketingBatchService.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Member;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Member\MemberMarketingBatchRepository;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
final class MemberMarketingBatchService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberMarketingBatchRepository $repo
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(string $asOfDate): array
|
||||||
|
{
|
||||||
|
// 방어: 같은 날 중복 실행 방지(스케줄 withoutOverlapping + 추가 안전장치)
|
||||||
|
$lockKey = "lock:marketing:members:build-stats:{$asOfDate}";
|
||||||
|
$lock = Cache::lock($lockKey, 3600);
|
||||||
|
|
||||||
|
if (!$lock->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
600
resources/views/admin/members/join_filters/index.blade.php
Normal file
600
resources/views/admin/members/join_filters/index.blade.php
Normal file
@ -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')
|
||||||
|
<style>
|
||||||
|
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
|
||||||
|
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
|
||||||
|
.filters .sel{width:180px;}
|
||||||
|
.filters .ip{width:240px;}
|
||||||
|
|
||||||
|
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
||||||
|
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
|
||||||
|
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
|
||||||
|
.lbtn--ghost{background:transparent;}
|
||||||
|
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
|
||||||
|
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
|
||||||
|
.lbtn--danger{background:rgba(244,63,94,.88);border-color:rgba(244,63,94,.95);color:#fff;}
|
||||||
|
.lbtn--danger:hover{background:rgba(244,63,94,.98);}
|
||||||
|
|
||||||
|
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
|
||||||
|
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
|
||||||
|
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);} /* A */
|
||||||
|
.pill--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);} /* S */
|
||||||
|
.pill--muted{opacity:.9;} /* N */
|
||||||
|
|
||||||
|
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
|
||||||
|
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}
|
||||||
|
|
||||||
|
.line_through{ text-decoration:line-through; color:#b7b7b7; opacity:.9; }
|
||||||
|
|
||||||
|
/* modal */
|
||||||
|
.mback{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:9999;
|
||||||
|
background:rgba(0,0,0,.55);backdrop-filter:blur(2px);}
|
||||||
|
.mback.is-open{display:flex;}
|
||||||
|
.modalx{width:min(860px, 92vw);max-height:88vh;overflow:auto;border-radius:18px;
|
||||||
|
border:1px solid rgba(255,255,255,.12);background:rgba(18,18,18,.96);box-shadow:0 20px 60px rgba(0,0,0,.45);}
|
||||||
|
.modalx__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:16px 16px 10px;border-bottom:1px solid rgba(255,255,255,.10);}
|
||||||
|
.modalx__title{font-weight:900;font-size:16px;}
|
||||||
|
.modalx__desc{font-size:12px;opacity:.8;margin-top:4px;}
|
||||||
|
.modalx__body{padding:16px;}
|
||||||
|
.modalx__foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid rgba(255,255,255,.10);}
|
||||||
|
|
||||||
|
.fgrid{display:grid;grid-template-columns:1fr;gap:12px;}
|
||||||
|
@media (min-width: 860px){ .fgrid{grid-template-columns:1fr 1fr;} }
|
||||||
|
|
||||||
|
.field{display:flex;flex-direction:column;gap:6px;}
|
||||||
|
.field label{font-size:12px;opacity:.85;}
|
||||||
|
.help{font-size:12px;opacity:.75;line-height:1.6;}
|
||||||
|
.radios{display:flex;gap:14px;flex-wrap:wrap;align-items:center;padding:10px;border-radius:14px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);}
|
||||||
|
.radios label{display:inline-flex;gap:8px;align-items:center;font-size:13px;opacity:.95;margin:0;}
|
||||||
|
.warnbox{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.10);border-radius:16px;padding:12px;}
|
||||||
|
.warnbox b{font-weight:900;}
|
||||||
|
|
||||||
|
.adminGrid{display:grid;grid-template-columns:1fr;gap:10px;margin-top:6px;}
|
||||||
|
@media (min-width: 860px){ .adminGrid{grid-template-columns:1fr 1fr;} }
|
||||||
|
.adminItem{display:flex;gap:10px;align-items:center;padding:10px;border-radius:14px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);}
|
||||||
|
.adminItem input[type=checkbox]{transform:scale(1.05);}
|
||||||
|
.adminItem__txt{display:flex;flex-direction:column;gap:4px;min-width:0;}
|
||||||
|
.adminItem__txt .t{font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.adminItem__txt .d{font-size:12px;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
</style>
|
||||||
|
@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
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="bar">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:900;font-size:16px;">회원가입 필터</div>
|
||||||
|
<div class="a-muted" style="font-size:12px;margin-top:4px;">등록/수정은 모달에서 처리 · 수정은 AJAX로 row 로드</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;">
|
||||||
|
<form method="GET" action="{{ $indexUrl }}" class="filters">
|
||||||
|
<div>
|
||||||
|
<select class="a-input sel" name="gubun_code">
|
||||||
|
<option value="">필터구분 전체</option>
|
||||||
|
<option value="02" {{ $gSel==='02'?'selected':'' }}>D-class</option>
|
||||||
|
<option value="01" {{ $gSel==='01'?'selected':'' }}>C-class</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<select class="a-input sel" name="join_block">
|
||||||
|
<option value="">가입차단여부 전체</option>
|
||||||
|
<option value="A" {{ $jSel==='A'?'selected':'' }}>회원가입차단</option>
|
||||||
|
<option value="S" {{ $jSel==='S'?'selected':'' }}>관리자SMS알림</option>
|
||||||
|
<option value="N" {{ $jSel==='N'?'selected':'' }}>필터끔</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input class="a-input ip" name="filter_ip" value="{{ $ip }}" placeholder="아이피만 입력 (예: 192.168.0 / 192.168.0.1)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button class="lbtn lbtn--ghost" type="submit">검색</button>
|
||||||
|
<a class="lbtn lbtn--ghost" href="{{ $indexUrl }}">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button class="lbtn lbtn--primary" type="button" id="btnOpenCreate">+ 아이피차단 필터등록</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:10px;">총 <b>{{ $page->total() }}</b>건 (20개씩)</div>
|
||||||
|
|
||||||
|
<div style="overflow:auto;">
|
||||||
|
<table class="a-table table" style="width:100%; min-width:1100px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:80px;">SEQ</th>
|
||||||
|
<th style="width:210px;">필터구분 [코드]</th>
|
||||||
|
<th style="width:220px;">필터내용</th>
|
||||||
|
<th style="width:220px;">가입차단여부</th>
|
||||||
|
<th>관리자전화번호</th>
|
||||||
|
<th style="width:120px;">등록자</th>
|
||||||
|
<th style="width:170px;">등록시간</th>
|
||||||
|
<th style="width:190px;">처리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
@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
|
||||||
|
<tr class="{{ $trCls }}">
|
||||||
|
<td class="a-muted">{{ $seq }}</td>
|
||||||
|
<td>
|
||||||
|
{{ $r['gubun'] ?? '-' }}
|
||||||
|
<span class="a-muted">[{{ $r['gubun_code'] ?? '-' }}]</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="mono">{{ $r['filter'] ?? '-' }}</span></td>
|
||||||
|
<td>
|
||||||
|
<span class="pill {{ $styleCls }}">
|
||||||
|
● {{ $jbMap[$jb] ?? $jb }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@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 === '')
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@else
|
||||||
|
<span class="mono">{{ $phonesLine }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="a-muted">{{ $r['admin'] ?? '-' }}</td>
|
||||||
|
<td class="a-muted">{{ $r['dt_reg'] ?? '-' }}</td>
|
||||||
|
<td style="display:flex;gap:8px;align-items:center;justify-content:flex-end;">
|
||||||
|
<button type="button"
|
||||||
|
class="lbtn lbtn--ghost lbtn--sm btnEdit"
|
||||||
|
data-seq="{{ $seq }}">
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('admin.join-filters.destroy', ['seq'=>$seq], false) }}" style="display:inline;">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="_return_to" value="{{ request()->getRequestUri() }}">
|
||||||
|
<button class="lbtn lbtn--danger lbtn--sm" type="submit" onclick="return confirm('삭제할까요?');">삭제</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="8" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Modal --}}
|
||||||
|
<div class="mback" id="joinFilterModalBack" aria-hidden="true">
|
||||||
|
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="joinFilterModalTitle">
|
||||||
|
<div class="modalx__head">
|
||||||
|
<div>
|
||||||
|
<div class="modalx__title" id="joinFilterModalTitle">회원가입 알림/차단 아이피 등록</div>
|
||||||
|
<div class="modalx__desc" id="joinFilterModalDesc">C/D class 아이피 필터를 입력하고 처리방법을 선택하세요.</div>
|
||||||
|
</div>
|
||||||
|
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="btnCloseModal">닫기 ✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" id="joinFilterForm" action="{{ $storeUrl }}">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="_return_to" value="{{ request()->getRequestUri() }}">
|
||||||
|
|
||||||
|
{{-- ✅ join_block != S 일 때 disabled된 체크값을 hidden으로 보강 제출 --}}
|
||||||
|
<div id="adminPhoneHidden"></div>
|
||||||
|
|
||||||
|
<div class="modalx__body">
|
||||||
|
<input type="hidden" name="filter_seq" id="filter_seq" value="">
|
||||||
|
|
||||||
|
{{-- ✅ 서버 에러가 있으면 모달 안에서 바로 보이게 --}}
|
||||||
|
@if($errors->any())
|
||||||
|
<div class="warnbox" style="margin-bottom:12px;">
|
||||||
|
<b>저장 실패</b><br>
|
||||||
|
<div style="margin-top:6px; font-size:12px; line-height:1.6;">
|
||||||
|
@foreach($errors->all() as $msg)
|
||||||
|
<div>• {{ $msg }}</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="fgrid">
|
||||||
|
<div class="field">
|
||||||
|
<label>필터구분</label>
|
||||||
|
<select class="a-input" name="gubun_code" id="gubun_code">
|
||||||
|
<option value="01">IP C class (192.168.0)</option>
|
||||||
|
<option value="02">IP D class (192.168.0.1)</option>
|
||||||
|
</select>
|
||||||
|
<div class="help">C-class: 3옥텟 / D-class: 4옥텟</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>필터내용 (아이피만)</label>
|
||||||
|
<input class="a-input" type="text" name="filter" id="filter" placeholder="예: 192.168.0 또는 192.168.0.1">
|
||||||
|
<div class="help">숫자와 점(.)만 입력</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-top:12px;">
|
||||||
|
<label>처리방법</label>
|
||||||
|
<div class="radios" id="join_block_wrap">
|
||||||
|
<label><input type="radio" name="join_block" value="S"> <span class="pill pill--warn">● 관리자SMS알림</span></label>
|
||||||
|
<label><input type="radio" name="join_block" value="A"> <span class="pill pill--bad">● 회원가입차단</span></label>
|
||||||
|
<label><input type="radio" name="join_block" value="N"> <span class="pill pill--muted">● 필터끔</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-top:14px;">
|
||||||
|
<label>SMS로 알람받을 관리자 선택</label>
|
||||||
|
<div class="help">
|
||||||
|
- <b>관리자SMS알림</b>일 때만 선택/변경 가능합니다.<br>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adminGrid" id="adminGrid">
|
||||||
|
@foreach(($admins ?? []) as $a0)
|
||||||
|
@php
|
||||||
|
$a = is_array($a0) ? $a0 : (array)$a0;
|
||||||
|
|
||||||
|
$pname = (string)($a['name'] ?? '');
|
||||||
|
$pemail = (string)($a['email'] ?? '');
|
||||||
|
$plain = (string)($a['phone_plain'] ?? '');
|
||||||
|
$plainDigits = preg_replace('/\D+/', '', $plain) ?: '';
|
||||||
|
$pdisp = (string)($a['phone_display'] ?? '');
|
||||||
|
$hasPhone = $plainDigits !== '';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<label class="adminItem" style="{{ $hasPhone ? '' : 'opacity:.55;' }}">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="admin_phone[]"
|
||||||
|
value="{{ $plainDigits }}"
|
||||||
|
data-orig-disabled="{{ $hasPhone ? '0' : '1' }}"
|
||||||
|
{{ $hasPhone ? '' : 'disabled' }}>
|
||||||
|
<div class="adminItem__txt">
|
||||||
|
<div class="t">{{ $pname !== '' ? $pname : '-' }}</div>
|
||||||
|
<div class="d">
|
||||||
|
{{ $pdisp !== '' ? $pdisp : '전화번호 없음' }}
|
||||||
|
@if($pemail !== '')
|
||||||
|
<span class="a-muted"> · {{ $pemail }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warnbox" style="margin-top:12px;">
|
||||||
|
<b>주의</b><br>
|
||||||
|
- 회원가입차단 선택 시 가입이 차단됩니다.<br>
|
||||||
|
- 필터끔은 리스트에 남기되 동작하지 않게 관리할 때 사용하세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modalx__foot">
|
||||||
|
<button class="lbtn lbtn--ghost" type="button" id="btnCancelModal">닫기</button>
|
||||||
|
<button class="lbtn lbtn--primary" type="submit" id="btnSubmitModal">저장하기</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const getTpl = @json($getTpl);
|
||||||
|
const updateTpl = @json($updateTpl);
|
||||||
|
const storeUrl = @json($storeUrl);
|
||||||
|
|
||||||
|
const modalBack = document.getElementById('joinFilterModalBack');
|
||||||
|
const btnOpenCreate = document.getElementById('btnOpenCreate');
|
||||||
|
const btnClose = document.getElementById('btnCloseModal');
|
||||||
|
const btnCancel = document.getElementById('btnCancelModal');
|
||||||
|
|
||||||
|
const form = document.getElementById('joinFilterForm');
|
||||||
|
const titleEl = document.getElementById('joinFilterModalTitle');
|
||||||
|
const descEl = document.getElementById('joinFilterModalDesc');
|
||||||
|
|
||||||
|
const seqEl = document.getElementById('filter_seq');
|
||||||
|
const gubunEl = document.getElementById('gubun_code');
|
||||||
|
const filterEl = document.getElementById('filter');
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('btnSubmitModal');
|
||||||
|
const hiddenBox = document.getElementById('adminPhoneHidden');
|
||||||
|
|
||||||
|
const radios = Array.from(document.querySelectorAll('input[name="join_block"]'));
|
||||||
|
const adminChecks = Array.from(document.querySelectorAll('#adminGrid input[type="checkbox"][name="admin_phone[]"]'));
|
||||||
|
|
||||||
|
const digits = (s) => String(s || '').replace(/\D+/g, '');
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
modalBack.classList.add('is-open');
|
||||||
|
modalBack.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
};
|
||||||
|
const closeModal = () => {
|
||||||
|
modalBack.classList.remove('is-open');
|
||||||
|
modalBack.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentJoinBlock = () => radios.find(r => r.checked)?.value || 'N';
|
||||||
|
|
||||||
|
const syncHiddenPhones = () => {
|
||||||
|
if (!hiddenBox) return;
|
||||||
|
hiddenBox.innerHTML = '';
|
||||||
|
|
||||||
|
const jb = currentJoinBlock();
|
||||||
|
if (jb === 'S') return;
|
||||||
|
|
||||||
|
adminChecks.forEach(chk => {
|
||||||
|
if (chk.getAttribute('data-orig-disabled') === '1') return;
|
||||||
|
if (!chk.checked) return;
|
||||||
|
|
||||||
|
const v = digits(chk.value);
|
||||||
|
if (!v) return;
|
||||||
|
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = 'hidden';
|
||||||
|
inp.name = 'admin_phone[]';
|
||||||
|
inp.value = v;
|
||||||
|
hiddenBox.appendChild(inp);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAdminChecksEnabled = (enabled) => {
|
||||||
|
adminChecks.forEach(chk => {
|
||||||
|
if (chk.getAttribute('data-orig-disabled') === '1') return;
|
||||||
|
chk.disabled = !enabled;
|
||||||
|
});
|
||||||
|
syncHiddenPhones();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickJoinBlock = (v) => {
|
||||||
|
radios.forEach(r => r.checked = (r.value === v));
|
||||||
|
setAdminChecksEnabled(v === 'S');
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAdminChecks = () => {
|
||||||
|
adminChecks.forEach(chk => {
|
||||||
|
if (chk.getAttribute('data-orig-disabled') === '1') return;
|
||||||
|
chk.checked = false;
|
||||||
|
});
|
||||||
|
syncHiddenPhones();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFormForCreate = () => {
|
||||||
|
titleEl.textContent = '회원가입 알림/차단 아이피 등록';
|
||||||
|
descEl.textContent = 'C/D class 아이피 필터를 입력하고 처리방법을 선택하세요.';
|
||||||
|
form.action = storeUrl;
|
||||||
|
seqEl.value = '';
|
||||||
|
|
||||||
|
gubunEl.value = '01';
|
||||||
|
filterEl.value = '';
|
||||||
|
|
||||||
|
pickJoinBlock('S');
|
||||||
|
clearAdminChecks();
|
||||||
|
|
||||||
|
submitBtn.textContent = '등록';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyAdminChecksFromRow = (row) => {
|
||||||
|
const arr = Array.isArray(row.admin_phone_arr) ? row.admin_phone_arr : [];
|
||||||
|
const set = new Set(arr.map(v => digits(v)).filter(v => v));
|
||||||
|
|
||||||
|
adminChecks.forEach(chk => {
|
||||||
|
if (chk.getAttribute('data-orig-disabled') === '1') return;
|
||||||
|
chk.checked = set.has(digits(chk.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
syncHiddenPhones();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillFormForEdit = (row) => {
|
||||||
|
const seq = row.seq ?? row.filter_seq ?? '';
|
||||||
|
titleEl.textContent = `회원가입 필터 수정 #${seq}`;
|
||||||
|
descEl.textContent = '수정 후 저장하기를 누르면 즉시 반영됩니다.';
|
||||||
|
form.action = updateTpl.replace('__SEQ__', encodeURIComponent(seq));
|
||||||
|
seqEl.value = String(seq);
|
||||||
|
|
||||||
|
gubunEl.value = String(row.gubun_code ?? '01');
|
||||||
|
filterEl.value = String(row.filter ?? '');
|
||||||
|
|
||||||
|
const jb = String(row.join_block ?? 'N');
|
||||||
|
pickJoinBlock(jb);
|
||||||
|
|
||||||
|
applyAdminChecksFromRow(row);
|
||||||
|
|
||||||
|
submitBtn.textContent = '저장';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// radio change
|
||||||
|
radios.forEach(r => {
|
||||||
|
r.addEventListener('change', () => {
|
||||||
|
const v = currentJoinBlock();
|
||||||
|
setAdminChecksEnabled(v === 'S');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// open create
|
||||||
|
btnOpenCreate?.addEventListener('click', () => {
|
||||||
|
resetFormForCreate();
|
||||||
|
openModal();
|
||||||
|
setTimeout(() => filterEl?.focus(), 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
// close
|
||||||
|
btnClose?.addEventListener('click', closeModal);
|
||||||
|
btnCancel?.addEventListener('click', closeModal);
|
||||||
|
modalBack?.addEventListener('click', (e) => { if (e.target === modalBack) closeModal(); });
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modalBack.classList.contains('is-open')) closeModal(); });
|
||||||
|
|
||||||
|
// submit
|
||||||
|
form?.addEventListener('submit', () => {
|
||||||
|
syncHiddenPhones();
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ edit -> AJAX get
|
||||||
|
Array.from(document.querySelectorAll('.btnEdit')).forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const seq = btn.getAttribute('data-seq');
|
||||||
|
if (!seq) return;
|
||||||
|
|
||||||
|
const url = getTpl.replace('__SEQ__', encodeURIComponent(seq));
|
||||||
|
|
||||||
|
try {
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 419) throw new Error('AUTH');
|
||||||
|
if (res.status === 403) throw new Error('FORBIDDEN');
|
||||||
|
if (!res.ok) throw new Error('HTTP_' + res.status);
|
||||||
|
|
||||||
|
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (!ct.includes('application/json')) throw new Error('NOT_JSON');
|
||||||
|
|
||||||
|
const payload = await res.json();
|
||||||
|
|
||||||
|
// ✅ 컨트롤러 응답이 {ok,row} 인데 row를 직접 쓰던게 버그였음
|
||||||
|
const row = payload?.row ?? payload;
|
||||||
|
if (!row) throw new Error('NOT_FOUND');
|
||||||
|
|
||||||
|
// 방어: admin_phone_arr 없으면 admin_phone에서 복구
|
||||||
|
if (!Array.isArray(row.admin_phone_arr)) {
|
||||||
|
try {
|
||||||
|
const a = JSON.parse(row.admin_phone || '[]');
|
||||||
|
row.admin_phone_arr = Array.isArray(a) ? a : [];
|
||||||
|
} catch (e) {
|
||||||
|
row.admin_phone_arr = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fillFormForEdit(row);
|
||||||
|
openModal();
|
||||||
|
setTimeout(() => filterEl?.focus(), 30);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e.message) === 'AUTH') alert('세션이 만료되었습니다. 다시 로그인해 주세요.');
|
||||||
|
else if (String(e.message) === 'FORBIDDEN') alert('권한이 없습니다. (super_admin만 접근)');
|
||||||
|
else if (String(e.message) === 'NOT_JSON') alert('응답이 JSON이 아닙니다. (로그인 리다이렉트/APP_URL/프록시 가능)');
|
||||||
|
else alert('수정 정보를 불러오지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// default
|
||||||
|
pickJoinBlock('S');
|
||||||
|
|
||||||
|
// ✅ 서버 validation 에러가 있으면 모달 다시 열어서 입력값 복구
|
||||||
|
const hasErrors = @json($errors->any());
|
||||||
|
const oldPayload = @js($oldPayload); // ✅ ParseError 방지 + JS 안전 변환
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
const seq = String(oldPayload.filter_seq || '');
|
||||||
|
const row = {
|
||||||
|
seq: seq,
|
||||||
|
gubun_code: oldPayload.gubun_code,
|
||||||
|
filter: oldPayload.filter,
|
||||||
|
join_block: oldPayload.join_block,
|
||||||
|
admin_phone_arr: Array.isArray(oldPayload.admin_phone) ? oldPayload.admin_phone : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (seq) fillFormForEdit(row);
|
||||||
|
else {
|
||||||
|
resetFormForCreate();
|
||||||
|
gubunEl.value = String(oldPayload.gubun_code || '01');
|
||||||
|
filterEl.value = String(oldPayload.filter || '');
|
||||||
|
pickJoinBlock(String(oldPayload.join_block || 'S'));
|
||||||
|
applyAdminChecksFromRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal();
|
||||||
|
setTimeout(() => filterEl?.focus(), 30);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
424
resources/views/admin/members/marketing.blade.php
Normal file
424
resources/views/admin/members/marketing.blade.php
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '마케팅 회원 추출')
|
||||||
|
@section('page_title', '마케팅 회원 추출')
|
||||||
|
@section('page_desc', '조건에 맞는 회원을 빠르게 조회하고, 전체를 ZIP(암호)로 내려받습니다. (화면은 상위 10건만 표시)')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
||||||
|
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
|
||||||
|
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
|
||||||
|
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
|
||||||
|
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
|
||||||
|
|
||||||
|
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
|
||||||
|
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
|
||||||
|
|
||||||
|
.kvgrid{display:grid;grid-template-columns:1fr;gap:12px;}
|
||||||
|
@media (min-width: 980px){ .kvgrid{grid-template-columns:1fr 1fr 1fr;} }
|
||||||
|
.kv{padding:14px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04);}
|
||||||
|
.kv .k{font-size:12px;opacity:.8;margin-bottom:6px;}
|
||||||
|
.kv .v{font-weight:900;}
|
||||||
|
|
||||||
|
.grid2{display:grid;grid-template-columns:1fr;gap:12px;}
|
||||||
|
@media (min-width: 980px){ .grid2{grid-template-columns:1.15fr .85fr;} }
|
||||||
|
|
||||||
|
.warnbox{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.10);border-radius:16px;padding:12px;}
|
||||||
|
.warnbox b{font-weight:900;}
|
||||||
|
|
||||||
|
/* radio chips */
|
||||||
|
.chipset{display:flex;flex-wrap:wrap;gap:8px;}
|
||||||
|
.chip{position:relative;}
|
||||||
|
.chip input{position:absolute;opacity:0;pointer-events:none;}
|
||||||
|
.chip label{
|
||||||
|
display:inline-flex;align-items:center;gap:6px;
|
||||||
|
padding:7px 10px;border-radius:999px;font-size:12px;cursor:pointer;
|
||||||
|
border:1px solid rgba(255,255,255,.12);
|
||||||
|
background:rgba(255,255,255,.05);
|
||||||
|
}
|
||||||
|
.chip input:checked + label{
|
||||||
|
border-color:rgba(59,130,246,.75);
|
||||||
|
background:rgba(59,130,246,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
details.optbox{border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);border-radius:14px;padding:10px;}
|
||||||
|
details.optbox summary{cursor:pointer; font-weight:900; list-style:none;}
|
||||||
|
details.optbox summary::-webkit-details-marker{display:none;}
|
||||||
|
|
||||||
|
.help{font-size:12px;opacity:.85;margin-top:6px;}
|
||||||
|
</style>
|
||||||
|
@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
|
||||||
|
|
||||||
|
<div class="kvgrid" style="margin-bottom:16px;">
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">조건 매칭 건수</div>
|
||||||
|
<div class="v">{{ number_format((int)($total ?? 0)) }} 건</div>
|
||||||
|
</div>
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">화면 표시</div>
|
||||||
|
<div class="v">상위 10건만 미리보기</div>
|
||||||
|
</div>
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">통계 기준일</div>
|
||||||
|
<div class="v"><span class="mono">{{ $as_of_date ?? '-' }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid2" style="margin-bottom:16px;">
|
||||||
|
{{-- 검색/필터 --}}
|
||||||
|
<form method="GET" action="{{ route('admin.marketing.index') }}">
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div style="font-weight:900; margin-bottom:12px;">검색/필터</div>
|
||||||
|
|
||||||
|
{{-- stat_3 --}}
|
||||||
|
<div class="a-field" style="margin-bottom:12px;">
|
||||||
|
<label class="a-label">회원상태 (stat_3)</label>
|
||||||
|
<div class="chipset">
|
||||||
|
<span class="chip">
|
||||||
|
<input id="s3_all" type="radio" name="stat_3" value="" {{ $stat3==='' ? 'checked' : '' }}>
|
||||||
|
<label for="s3_all">전체</label>
|
||||||
|
</span>
|
||||||
|
@foreach(($stat3Map ?? []) as $k=>$label)
|
||||||
|
<span class="chip">
|
||||||
|
<input id="s3_{{ $k }}" type="radio" name="stat_3" value="{{ $k }}" {{ $stat3===(string)$k ? 'checked' : '' }}>
|
||||||
|
<label for="s3_{{ $k }}">{{ $label }}</label>
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 가입일 --}}
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:12px;">
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">가입일 시작</label>
|
||||||
|
<input class="a-input" type="date" name="reg_from" value="{{ $regFrom }}" data-date>
|
||||||
|
</div>
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">가입일 종료</label>
|
||||||
|
<input class="a-input" type="date" name="reg_to" value="{{ $regTo }}" data-date>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 로그인 필터 (둘 중 하나만) --}}
|
||||||
|
<div class="a-field" style="margin-bottom:12px;">
|
||||||
|
<label class="a-label">로그인 기준 필터 (1개만 선택)</label>
|
||||||
|
|
||||||
|
<div class="chipset" style="margin-bottom:10px;">
|
||||||
|
<span class="chip">
|
||||||
|
<input id="lm_none" type="radio" name="login_mode" value="none" {{ $loginMode==='none' ? 'checked' : '' }}>
|
||||||
|
<label for="lm_none">사용 안함</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="lm_inactive" type="radio" name="login_mode" value="inactive" {{ $loginMode==='inactive' ? 'checked' : '' }}>
|
||||||
|
<label for="lm_inactive">미접속일수(이상)</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="lm_recent" type="radio" name="login_mode" value="recent" {{ $loginMode==='recent' ? 'checked' : '' }}>
|
||||||
|
<label for="lm_recent">최근 로그인(이내)</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input class="a-input" type="number" name="login_days" min="0" value="{{ $loginDays }}"
|
||||||
|
placeholder="예: 30" id="login_days_input">
|
||||||
|
<div class="help">
|
||||||
|
- <b>미접속일수(이상)</b>: 최근 로그인으로부터 N일 이상 지난 회원<br>
|
||||||
|
- <b>최근 로그인(이내)</b>: 최근 로그인으로부터 N일 이내인 회원 (예: 30 → 최근 30일 내 로그인)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 구매 조건 --}}
|
||||||
|
<div class="a-field" style="margin-bottom:12px;">
|
||||||
|
<label class="a-label">구매 여부</label>
|
||||||
|
<div class="chipset" style="margin-bottom:10px;">
|
||||||
|
<span class="chip">
|
||||||
|
<input id="hp_all" type="radio" name="has_purchase" value="all" {{ $hasPur==='all' ? 'checked' : '' }}>
|
||||||
|
<label for="hp_all">전체</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="hp_1" type="radio" name="has_purchase" value="1" {{ $hasPur==='1' ? 'checked' : '' }}>
|
||||||
|
<label for="hp_1">구매 있음</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="hp_0" type="radio" name="has_purchase" value="0" {{ $hasPur==='0' ? 'checked' : '' }}>
|
||||||
|
<label for="hp_0">구매 없음</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="a-label">최근 구매</label>
|
||||||
|
<div class="chipset" style="margin-bottom:10px;">
|
||||||
|
<span class="chip">
|
||||||
|
<input id="rp_all" type="radio" name="recent_purchase" value="all" {{ $recent==='all' ? 'checked' : '' }}>
|
||||||
|
<label for="rp_all">전체</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="rp_30" type="radio" name="recent_purchase" value="30" {{ $recent==='30' ? 'checked' : '' }}>
|
||||||
|
<label for="rp_30">최근 30일 구매</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="rp_90" type="radio" name="recent_purchase" value="90" {{ $recent==='90' ? 'checked' : '' }}>
|
||||||
|
<label for="rp_90">최근 90일 구매</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="optbox" style="margin-bottom:0;">
|
||||||
|
<summary>구매 상세 조건(선택)</summary>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px;">
|
||||||
|
<div>
|
||||||
|
<label class="a-label">최소 누적구매횟수</label>
|
||||||
|
<input class="a-input" type="number" name="min_purchase_count" min="0" value="{{ $minCnt }}" placeholder="예: 1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="a-label">최소 누적구매금액</label>
|
||||||
|
<input class="a-input" type="number" name="min_purchase_amount" min="0" value="{{ $minAmt }}" placeholder="예: 10000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 추가 옵션 --}}
|
||||||
|
<details class="optbox" style="margin-bottom:12px;">
|
||||||
|
<summary>추가 옵션 (수신동의/정보유무)</summary>
|
||||||
|
|
||||||
|
<div style="margin-top:10px;">
|
||||||
|
<label class="a-label">SMS 수신동의</label>
|
||||||
|
<div class="chipset">
|
||||||
|
<span class="chip">
|
||||||
|
<input id="os_all" type="radio" name="optin_sms" value="all" {{ $optSms==='all' ? 'checked' : '' }}>
|
||||||
|
<label for="os_all">전체</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="os_1" type="radio" name="optin_sms" value="1" {{ $optSms==='1' ? 'checked' : '' }}>
|
||||||
|
<label for="os_1">수신동의</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="os_0" type="radio" name="optin_sms" value="0" {{ $optSms==='0' ? 'checked' : '' }}>
|
||||||
|
<label for="os_0">수신미동의</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:10px;">
|
||||||
|
<label class="a-label">이메일 수신동의</label>
|
||||||
|
<div class="chipset">
|
||||||
|
<span class="chip">
|
||||||
|
<input id="oe_all" type="radio" name="optin_email" value="all" {{ $optEmail==='all' ? 'checked' : '' }}>
|
||||||
|
<label for="oe_all">전체</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="oe_1" type="radio" name="optin_email" value="1" {{ $optEmail==='1' ? 'checked' : '' }}>
|
||||||
|
<label for="oe_1">수신동의</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="oe_0" type="radio" name="optin_email" value="0" {{ $optEmail==='0' ? 'checked' : '' }}>
|
||||||
|
<label for="oe_0">수신미동의</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:10px;">
|
||||||
|
<label class="a-label">전화번호 있음</label>
|
||||||
|
<div class="chipset">
|
||||||
|
<span class="chip">
|
||||||
|
<input id="hp2_all" type="radio" name="has_phone" value="all" {{ $hasPhone==='all' ? 'checked' : '' }}>
|
||||||
|
<label for="hp2_all">전체</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="hp2_1" type="radio" name="has_phone" value="1" {{ $hasPhone==='1' ? 'checked' : '' }}>
|
||||||
|
<label for="hp2_1">있음</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="hp2_0" type="radio" name="has_phone" value="0" {{ $hasPhone==='0' ? 'checked' : '' }}>
|
||||||
|
<label for="hp2_0">없음</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:10px;">
|
||||||
|
<label class="a-label">이메일 있음</label>
|
||||||
|
<div class="chipset">
|
||||||
|
<span class="chip">
|
||||||
|
<input id="he2_all" type="radio" name="has_email" value="all" {{ $hasEmail==='all' ? 'checked' : '' }}>
|
||||||
|
<label for="he2_all">전체</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="he2_1" type="radio" name="has_email" value="1" {{ $hasEmail==='1' ? 'checked' : '' }}>
|
||||||
|
<label for="he2_1">있음</label>
|
||||||
|
</span>
|
||||||
|
<span class="chip">
|
||||||
|
<input id="he2_0" type="radio" name="has_email" value="0" {{ $hasEmail==='0' ? 'checked' : '' }}>
|
||||||
|
<label for="he2_0">없음</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<button class="lbtn lbtn--primary" type="submit">조회</button>
|
||||||
|
<a class="lbtn" href="{{ route('admin.marketing.index') }}">초기화</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warnbox" style="margin-top:12px;">
|
||||||
|
<b>안내:</b> 화면은 상위 10건만 표시합니다. 다운로드는 조건에 매칭되는 전체 데이터가 포함됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{-- 다운로드 --}}
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div style="font-weight:900; margin-bottom:10px;">전체 다운로드 (ZIP 암호화)</div>
|
||||||
|
|
||||||
|
<div class="warnbox" style="margin-bottom:12px;">
|
||||||
|
- 다운로드 파일에는 <b>성명/전화번호/이메일</b>이 포함됩니다.<br>
|
||||||
|
- ZIP 비밀번호는 서버에 저장하지 않습니다. 분실 주의
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('admin.marketing.export') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
{{-- filters hidden --}}
|
||||||
|
@foreach(($filters ?? []) as $k=>$v)
|
||||||
|
@if(is_scalar($v) && $k !== 'phone_enc')
|
||||||
|
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
<div class="a-field" style="margin-bottom:12px;">
|
||||||
|
<label class="a-label">ZIP 비밀번호(필수)</label>
|
||||||
|
<input class="a-input" type="password" name="zip_password" required minlength="4" maxlength="64"
|
||||||
|
placeholder="예: 1234 또는 강력한 비밀번호">
|
||||||
|
@error('zip_password') <div style="color:#ff4d4f;margin-top:6px;">{{ $message }}</div> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="lbtn lbtn--primary" type="submit">
|
||||||
|
전체 다운로드 ({{ number_format((int)($total ?? 0)) }}건)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 미리보기 --}}
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||||
|
<div style="font-weight:900;">미리보기 (상위 10건)</div>
|
||||||
|
<div class="a-muted" style="font-size:12px;">총 {{ number_format((int)($total ?? 0)) }}건 중 상위 10건</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px; overflow:auto;">
|
||||||
|
<table class="a-table" style="width:100%; min-width:1200px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:90px;">회원번호</th>
|
||||||
|
<th style="width:140px;">성명</th>
|
||||||
|
<th style="width:150px;">전화번호</th>
|
||||||
|
<th style="width:240px;">이메일</th>
|
||||||
|
<th style="width:220px;">회원상태</th>
|
||||||
|
<th style="width:150px;">가입일시</th>
|
||||||
|
<th style="width:150px;">최근로그인</th>
|
||||||
|
<th style="width:110px;">미접속일수</th>
|
||||||
|
<th style="width:110px;">누적구매횟수</th>
|
||||||
|
<th style="width:140px;">누적구매금액</th>
|
||||||
|
<th style="width:150px;">최근구매일</th>
|
||||||
|
<th style="width:110px;">SMS수신</th>
|
||||||
|
<th style="width:110px;">이메일수신</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse(($rows ?? []) as $r)
|
||||||
|
@php
|
||||||
|
$no = (int)($r['mem_no'] ?? 0);
|
||||||
|
$inactiveDays = $r['days_since_login'];
|
||||||
|
$inactiveDays = ($inactiveDays === null ? '-' : (string)$inactiveDays);
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td><a class="mono" href="{{ route('admin.members.show', ['memNo'=>$no]) }}">#{{ $no }}</a></td>
|
||||||
|
<td>{{ $r['name'] ?? '-' }}</td>
|
||||||
|
<td><span class="mono">{{ $r['phone_display'] ?? '-' }}</span></td>
|
||||||
|
<td><span class="mono">{{ $r['email'] ?? '' }}</span></td>
|
||||||
|
<td><span class="mono">{{ $r['stat_3_label'] ?? ($r['stat_3'] ?? '') }}</span></td>
|
||||||
|
<td class="a-muted">{{ $r['reg_at'] ?? '-' }}</td>
|
||||||
|
<td class="a-muted">{{ $r['last_login_at'] ?? '-' }}</td>
|
||||||
|
<td class="a-muted">{{ $inactiveDays }}</td>
|
||||||
|
<td class="a-muted">{{ number_format((int)($r['purchase_count_total'] ?? 0)) }}</td>
|
||||||
|
<td class="a-muted">{{ number_format((int)($r['purchase_amount_total'] ?? 0)) }}</td>
|
||||||
|
<td class="a-muted">{{ $r['last_purchase_at'] ?? '-' }}</td>
|
||||||
|
<td class="a-muted">{{ ((int)($r['optin_sms'] ?? 0)===1) ? '수신동의' : '수신미동의' }}</td>
|
||||||
|
<td class="a-muted">{{ ((int)($r['optin_email'] ?? 0)===1) ? '수신동의' : '수신미동의' }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="13" class="a-muted" style="padding:12px;">조건에 해당하는 데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
// ✅ date input 클릭/포커스 시 달력 즉시 표시 (Chrome/Edge 지원)
|
||||||
|
document.querySelectorAll('input[type="date"][data-date]').forEach(function (el) {
|
||||||
|
const tryShowPicker = function (ev) {
|
||||||
|
// isTrusted: 실제 사용자 입력만 허용
|
||||||
|
if (ev && ev.isTrusted === false) return;
|
||||||
|
|
||||||
|
if (typeof el.showPicker === 'function') {
|
||||||
|
try {
|
||||||
|
el.showPicker();
|
||||||
|
} catch (e) {
|
||||||
|
// 일부 브라우저/상황에서 막히면 조용히 무시 (기본 동작에 맡김)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// focus는 user gesture로 인정 안 되는 케이스가 많아서 제거
|
||||||
|
el.addEventListener('pointerdown', tryShowPicker);
|
||||||
|
el.addEventListener('mousedown', tryShowPicker); // pointerdown 미지원 대비
|
||||||
|
el.addEventListener('click', tryShowPicker); // 마지막 보루
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ 로그인 필터: mode에 따라 숫자 입력 활성/비활성
|
||||||
|
const modeEls = document.querySelectorAll('input[name="login_mode"]');
|
||||||
|
const daysEl = document.getElementById('login_days_input');
|
||||||
|
|
||||||
|
function applyLoginMode() {
|
||||||
|
const mode = document.querySelector('input[name="login_mode"]:checked')?.value || 'none';
|
||||||
|
if (mode === 'none') {
|
||||||
|
daysEl.value = '';
|
||||||
|
daysEl.setAttribute('disabled', 'disabled');
|
||||||
|
} else {
|
||||||
|
daysEl.removeAttribute('disabled');
|
||||||
|
daysEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modeEls.forEach(r => r.addEventListener('change', applyLoginMode));
|
||||||
|
applyLoginMode();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
@ -18,10 +18,8 @@
|
|||||||
'title' => '회원/정책',
|
'title' => '회원/정책',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']],
|
['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']],
|
||||||
['label' => '회원 데이터추출', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']],
|
['label' => '회원 데이터추출', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']],
|
||||||
['label' => '로그인/가입 아이피 필터설정', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']],
|
['label' => '로그인/가입 아이피 필터설정', 'route' => 'admin.join-filters.index','roles' => ['super_admin','support']],
|
||||||
['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index','roles' => ['super_admin','support']],
|
|
||||||
['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@ -12,6 +12,9 @@ use App\Http\Controllers\Admin\Mail\AdminMailTemplateController;
|
|||||||
use App\Http\Controllers\Admin\Notice\AdminNoticeController;
|
use App\Http\Controllers\Admin\Notice\AdminNoticeController;
|
||||||
use App\Http\Controllers\Admin\Qna\AdminQnaController;
|
use App\Http\Controllers\Admin\Qna\AdminQnaController;
|
||||||
use App\Http\Controllers\Admin\Members\AdminMembersController;
|
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;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware(['web'])->group(function () {
|
Route::middleware(['web'])->group(function () {
|
||||||
@ -201,6 +204,44 @@ Route::middleware(['web'])->group(function () {
|
|||||||
->whereNumber('memNo')
|
->whereNumber('memNo')
|
||||||
->name('force_out');
|
->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');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -115,6 +115,12 @@ registerScheduleCron('admin_mail_dispatch_due', '* * * * *', 'admin-mail:dispatc
|
|||||||
'run_in_background' => true,
|
'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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user