Compare commits
3 Commits
9825350372
...
e80c0e7914
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e80c0e7914 | ||
|
|
23136fc1da | ||
|
|
466cb89307 |
@ -39,15 +39,31 @@ final class AdminProductController
|
|||||||
|
|
||||||
private function validateProduct(Request $request): array
|
private function validateProduct(Request $request): array
|
||||||
{
|
{
|
||||||
|
$methods = $request->input('payment_methods', []);
|
||||||
|
if (!is_array($methods)) $methods = [];
|
||||||
|
|
||||||
|
$card = $request->input('payment_card_method');
|
||||||
|
if ($card !== null && $card !== '') {
|
||||||
|
$methods[] = (int)$card;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->merge([
|
||||||
|
'payment_methods' => array_values(array_unique(array_map('intval', $methods))),
|
||||||
|
'pin_check_methods' => array_values(array_unique((array)$request->input('pin_check_methods', []))),
|
||||||
|
]);
|
||||||
|
|
||||||
return $request->validate([
|
return $request->validate([
|
||||||
'category_id' => ['required', 'integer'],
|
'category_id' => ['required', 'integer'],
|
||||||
'name' => ['required', 'string', 'max:150'],
|
'name' => ['required', 'string', 'max:150'],
|
||||||
'thumbnail_media_id' => ['nullable', 'integer'],
|
'thumbnail_media_id' => ['nullable', 'integer'],
|
||||||
'status' => ['required', 'in:ACTIVE,HIDDEN,SOLD_OUT'],
|
'status' => ['required', 'in:ACTIVE,HIDDEN,SOLD_OUT'],
|
||||||
'product_type' => ['required', 'in:ONLINE,DELIVERY'],
|
'product_type' => ['required', 'in:ONLINE,DELIVERY'],
|
||||||
'is_buyback_allowed' => ['required', 'in:0,1'],
|
|
||||||
'payment_methods' => ['required', 'array', 'min:1'],
|
'payment_methods' => ['required', 'array', 'min:1'],
|
||||||
'payment_methods.*' => ['integer'],
|
'payment_methods.*' => ['integer'],
|
||||||
|
|
||||||
|
'pin_check_methods' => ['required', 'array', 'min:1'],
|
||||||
|
'pin_check_methods.*' => ['string', 'in:PIN_INSTANT,SMS,BUYBACK'],
|
||||||
|
|
||||||
'is_always_on_sale' => ['required', 'in:0,1'],
|
'is_always_on_sale' => ['required', 'in:0,1'],
|
||||||
'sales_start_at' => ['nullable', 'date'],
|
'sales_start_at' => ['nullable', 'date'],
|
||||||
'sales_end_at' => ['nullable', 'date', 'after_or_equal:sales_start_at'],
|
'sales_end_at' => ['nullable', 'date', 'after_or_equal:sales_start_at'],
|
||||||
@ -74,6 +90,7 @@ final class AdminProductController
|
|||||||
|
|
||||||
], [
|
], [
|
||||||
'payment_methods.required' => '최소 1개 이상의 결제 수단을 선택해주세요.',
|
'payment_methods.required' => '최소 1개 이상의 결제 수단을 선택해주세요.',
|
||||||
|
'pin_check_methods.required' => '핀번호 확인 방법을 최소 1개 이상 선택해주세요.',
|
||||||
'skus.required' => '최소 1개 이상의 권종을 등록해야 합니다.',
|
'skus.required' => '최소 1개 이상의 권종을 등록해야 합니다.',
|
||||||
'skus.*.api_provider_id.required_if' => 'API 연동 시 연동사를 선택해야 합니다.',
|
'skus.*.api_provider_id.required_if' => 'API 연동 시 연동사를 선택해야 합니다.',
|
||||||
'skus.*.api_product_code.required_if' => 'API 연동 시 상품 코드를 선택해야 합니다.',
|
'skus.*.api_product_code.required_if' => 'API 연동 시 상품 코드를 선택해야 합니다.',
|
||||||
|
|||||||
@ -23,30 +23,73 @@ final class AdminSaleCodeController
|
|||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'code' => ['required', 'string', 'max:30'],
|
'code' => ['required', 'string', 'max:30'],
|
||||||
'name' => ['required', 'string', 'max:50'],
|
'name' => ['required', 'string', 'max:50'],
|
||||||
|
'transport_type' => ['required', 'in:HTTP_FORM,HTTP_ENCRYPTED,TCP_SOCKET'],
|
||||||
|
'base_url' => ['nullable', 'string', 'max:255'],
|
||||||
|
'host' => ['nullable', 'string', 'max:100'],
|
||||||
|
'port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||||
|
'timeout_connect_sec' => ['required', 'integer', 'min:1', 'max:120'],
|
||||||
|
'timeout_read_sec' => ['required', 'integer', 'min:1', 'max:300'],
|
||||||
|
'charset' => ['required', 'string', 'max:20'],
|
||||||
|
'response_format_default' => ['nullable', 'string', 'max:10'],
|
||||||
|
'is_test_mode' => ['required', 'in:0,1'],
|
||||||
|
'supports_issue' => ['required', 'in:0,1'],
|
||||||
|
'supports_cancel' => ['required', 'in:0,1'],
|
||||||
|
'supports_resend' => ['required', 'in:0,1'],
|
||||||
|
'supports_cancel_check' => ['required', 'in:0,1'],
|
||||||
|
'supports_network_cancel' => ['required', 'in:0,1'],
|
||||||
|
'config_json' => ['nullable', 'string'],
|
||||||
'is_active' => ['required', 'in:0,1'],
|
'is_active' => ['required', 'in:0,1'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$res = $this->service->storeProvider($data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
|
$res = $this->service->storeProvider($data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
|
||||||
|
|
||||||
return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]);
|
return redirect()->back()->with('toast', [
|
||||||
|
'type' => $res['ok'] ? 'success' : 'danger',
|
||||||
|
'title' => '알림',
|
||||||
|
'message' => $res['message'],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateProvider(int $id, Request $request)
|
public function updateProvider(int $id, Request $request)
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:50'],
|
'name' => ['required', 'string', 'max:50'],
|
||||||
|
'transport_type' => ['required', 'in:HTTP_FORM,HTTP_ENCRYPTED,TCP_SOCKET'],
|
||||||
|
'base_url' => ['nullable', 'string', 'max:255'],
|
||||||
|
'host' => ['nullable', 'string', 'max:100'],
|
||||||
|
'port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||||
|
'timeout_connect_sec' => ['required', 'integer', 'min:1', 'max:120'],
|
||||||
|
'timeout_read_sec' => ['required', 'integer', 'min:1', 'max:300'],
|
||||||
|
'charset' => ['required', 'string', 'max:20'],
|
||||||
|
'response_format_default' => ['nullable', 'string', 'max:10'],
|
||||||
|
'is_test_mode' => ['required', 'in:0,1'],
|
||||||
|
'supports_issue' => ['required', 'in:0,1'],
|
||||||
|
'supports_cancel' => ['required', 'in:0,1'],
|
||||||
|
'supports_resend' => ['required', 'in:0,1'],
|
||||||
|
'supports_cancel_check' => ['required', 'in:0,1'],
|
||||||
|
'supports_network_cancel' => ['required', 'in:0,1'],
|
||||||
|
'config_json' => ['nullable', 'string'],
|
||||||
'is_active' => ['required', 'in:0,1'],
|
'is_active' => ['required', 'in:0,1'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$res = $this->service->updateProvider($id, $data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
|
$res = $this->service->updateProvider($id, $data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
|
||||||
|
|
||||||
return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]);
|
return redirect()->back()->with('toast', [
|
||||||
|
'type' => $res['ok'] ? 'success' : 'danger',
|
||||||
|
'title' => '알림',
|
||||||
|
'message' => $res['message'],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroyProvider(int $id, Request $request)
|
public function destroyProvider(int $id, Request $request)
|
||||||
{
|
{
|
||||||
$res = $this->service->deleteProvider($id, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
|
$res = $this->service->deleteProvider($id, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
|
||||||
return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]);
|
|
||||||
|
return redirect()->back()->with('toast', [
|
||||||
|
'type' => $res['ok'] ? 'success' : 'danger',
|
||||||
|
'title' => '알림',
|
||||||
|
'message' => $res['message'],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Product Code ---
|
// --- Product Code ---
|
||||||
|
|||||||
@ -4,14 +4,65 @@ namespace App\Http\Controllers\Web\Mypage;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\Mypage\UsageService;
|
use App\Services\Mypage\UsageService;
|
||||||
|
use App\Services\Payments\OwnPinIssueService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
final class UsageController extends Controller
|
final class UsageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UsageService $service,
|
private readonly UsageService $service,
|
||||||
|
private readonly OwnPinIssueService $ownPinIssueService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public function issuePinInstant(Request $request, int $attemptId)
|
||||||
|
{
|
||||||
|
if ((bool)session('_sess._login_') !== true) {
|
||||||
|
return redirect()->route('web.auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$memNo = (int)session('_sess._mno', 0);
|
||||||
|
if ($memNo <= 0) abort(403);
|
||||||
|
|
||||||
|
$out = $this->ownPinIssueService->issuePinInstant($attemptId, $memNo);
|
||||||
|
|
||||||
|
if (!($out['ok'] ?? false)) {
|
||||||
|
return redirect()->back()->with('error', (string)($out['message'] ?? '발행 실패'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('web.mypage.usage.show', ['attemptId' => $attemptId])
|
||||||
|
->with('success', (string)($out['message'] ?? '발행 완료'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revealPins(Request $request, int $attemptId)
|
||||||
|
{
|
||||||
|
if ((bool)session('_sess._login_') !== true) {
|
||||||
|
return redirect()->route('web.auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$memNo = (int)session('_sess._mno', 0);
|
||||||
|
if ($memNo <= 0) abort(403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'pin2' => ['required', 'string', 'min:4', 'max:20'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$out = $this->service->revealPins(
|
||||||
|
$attemptId,
|
||||||
|
$memNo,
|
||||||
|
(string)$data['pin2']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!($out['ok'] ?? false)) {
|
||||||
|
return redirect()
|
||||||
|
->route('web.mypage.usage.show', ['attemptId' => $attemptId])
|
||||||
|
->with('error', (string)($out['message'] ?? '핀번호 확인에 실패했습니다.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('web.mypage.usage.show', ['attemptId' => $attemptId, 'revealed' => 1])
|
||||||
|
->with('success', '핀번호 확인이 완료되었습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /mypage/usage
|
* GET /mypage/usage
|
||||||
* - 리스트 + 검색 + 페이징
|
* - 리스트 + 검색 + 페이징
|
||||||
|
|||||||
@ -6,9 +6,6 @@ use Illuminate\Support\Facades\DB;
|
|||||||
|
|
||||||
final class AdminSaleCodeRepository
|
final class AdminSaleCodeRepository
|
||||||
{
|
{
|
||||||
// ==========================================
|
|
||||||
// 1. Providers (API 연동사)
|
|
||||||
// ==========================================
|
|
||||||
public function getAllProviders(): array
|
public function getAllProviders(): array
|
||||||
{
|
{
|
||||||
return DB::table('gc_api_providers')
|
return DB::table('gc_api_providers')
|
||||||
@ -27,13 +24,17 @@ final class AdminSaleCodeRepository
|
|||||||
{
|
{
|
||||||
$data['created_at'] = now()->format('Y-m-d H:i:s');
|
$data['created_at'] = now()->format('Y-m-d H:i:s');
|
||||||
$data['updated_at'] = $data['created_at'];
|
$data['updated_at'] = $data['created_at'];
|
||||||
|
|
||||||
return DB::table('gc_api_providers')->insertGetId($data);
|
return DB::table('gc_api_providers')->insertGetId($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateProvider(int $id, array $data): bool
|
public function updateProvider(int $id, array $data): bool
|
||||||
{
|
{
|
||||||
$data['updated_at'] = now()->format('Y-m-d H:i:s');
|
$data['updated_at'] = now()->format('Y-m-d H:i:s');
|
||||||
return DB::table('gc_api_providers')->where('id', $id)->update($data) > 0;
|
|
||||||
|
return DB::table('gc_api_providers')
|
||||||
|
->where('id', $id)
|
||||||
|
->update($data) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteProvider(int $id): bool
|
public function deleteProvider(int $id): bool
|
||||||
@ -41,9 +42,6 @@ final class AdminSaleCodeRepository
|
|||||||
return DB::table('gc_api_providers')->where('id', $id)->delete() > 0;
|
return DB::table('gc_api_providers')->where('id', $id)->delete() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 2. Product Codes (상품 코드 매핑)
|
|
||||||
// ==========================================
|
|
||||||
public function getAllCodes(): array
|
public function getAllCodes(): array
|
||||||
{
|
{
|
||||||
return DB::table('gc_api_product_codes')
|
return DB::table('gc_api_product_codes')
|
||||||
@ -63,13 +61,17 @@ final class AdminSaleCodeRepository
|
|||||||
{
|
{
|
||||||
$data['created_at'] = now()->format('Y-m-d H:i:s');
|
$data['created_at'] = now()->format('Y-m-d H:i:s');
|
||||||
$data['updated_at'] = $data['created_at'];
|
$data['updated_at'] = $data['created_at'];
|
||||||
|
|
||||||
return DB::table('gc_api_product_codes')->insertGetId($data);
|
return DB::table('gc_api_product_codes')->insertGetId($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateCode(int $id, array $data): bool
|
public function updateCode(int $id, array $data): bool
|
||||||
{
|
{
|
||||||
$data['updated_at'] = now()->format('Y-m-d H:i:s');
|
$data['updated_at'] = now()->format('Y-m-d H:i:s');
|
||||||
return DB::table('gc_api_product_codes')->where('id', $id)->update($data) > 0;
|
|
||||||
|
return DB::table('gc_api_product_codes')
|
||||||
|
->where('id', $id)
|
||||||
|
->update($data) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteCode(int $id): bool
|
public function deleteCode(int $id): bool
|
||||||
@ -86,6 +88,8 @@ final class AdminSaleCodeRepository
|
|||||||
|
|
||||||
public function countCodesByProvider(int $providerId): int
|
public function countCodesByProvider(int $providerId): int
|
||||||
{
|
{
|
||||||
return DB::table('gc_api_product_codes')->where('provider_id', $providerId)->count();
|
return DB::table('gc_api_product_codes')
|
||||||
|
->where('provider_id', $providerId)
|
||||||
|
->count();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,47 @@ use Illuminate\Support\Facades\DB;
|
|||||||
|
|
||||||
final class UsageRepository
|
final class UsageRepository
|
||||||
{
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품별 핀 발행(확인) 방법 조회
|
||||||
|
* - gc_products.pin_check_methods: JSON 배열
|
||||||
|
* - gc_products.is_buyback_allowed: BUYBACK 안전장치
|
||||||
|
*/
|
||||||
|
public function getProductsIssueOptions(array $productIds): array
|
||||||
|
{
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $productIds), fn($n) => $n > 0)));
|
||||||
|
if (empty($ids)) return [];
|
||||||
|
|
||||||
|
$rows = DB::table('gc_products')
|
||||||
|
->select(['id', 'pin_check_methods', 'is_buyback_allowed'])
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$raw = $r->pin_check_methods ?? null;
|
||||||
|
$hasPinCheckMethods = !($raw === null || trim((string)$raw) === '');
|
||||||
|
|
||||||
|
$out[(int)$r->id] = [
|
||||||
|
'pin_check_methods' => $this->decodeJsonArray($raw),
|
||||||
|
'has_pin_check_methods' => $hasPinCheckMethods,
|
||||||
|
'is_buyback_allowed' => (bool)($r->is_buyback_allowed ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeJsonArray($json): array
|
||||||
|
{
|
||||||
|
if (is_array($json)) return $json;
|
||||||
|
|
||||||
|
$json = trim((string)$json);
|
||||||
|
if ($json === '') return [];
|
||||||
|
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
public function findAttemptWithOrder(int $attemptId): ?object
|
public function findAttemptWithOrder(int $attemptId): ?object
|
||||||
{
|
{
|
||||||
return DB::table('gc_payment_attempts as a')
|
return DB::table('gc_payment_attempts as a')
|
||||||
@ -41,6 +82,7 @@ final class UsageRepository
|
|||||||
'o.mem_no as order_mem_no',
|
'o.mem_no as order_mem_no',
|
||||||
'o.stat_pay as order_stat_pay',
|
'o.stat_pay as order_stat_pay',
|
||||||
'o.products_name as order_product_name',
|
'o.products_name as order_product_name',
|
||||||
|
'o.products_id as order_product_id',
|
||||||
'o.provider as order_provider',
|
'o.provider as order_provider',
|
||||||
'o.pay_method as order_pay_method',
|
'o.pay_method as order_pay_method',
|
||||||
'o.pg_tid as order_pg_tid',
|
'o.pg_tid as order_pg_tid',
|
||||||
@ -78,17 +120,23 @@ final class UsageRepository
|
|||||||
$from = trim((string)($filters['from'] ?? ''));
|
$from = trim((string)($filters['from'] ?? ''));
|
||||||
$to = trim((string)($filters['to'] ?? ''));
|
$to = trim((string)($filters['to'] ?? ''));
|
||||||
|
|
||||||
// order_items 집계 서브쿼리 (group by를 메인 쿼리에서 피해서 paginate 안정)
|
// 주문아이템 집계
|
||||||
$oiAgg = DB::table('gc_pin_order_items')
|
$oiAgg = DB::table('gc_pin_order_items')
|
||||||
->selectRaw('order_id, SUM(qty) as total_qty, MIN(item_name) as first_item_name')
|
->selectRaw('order_id, SUM(qty) as total_qty, MIN(item_name) as first_item_name')
|
||||||
->groupBy('order_id');
|
->groupBy('order_id');
|
||||||
|
|
||||||
|
// 발행 집계
|
||||||
|
$issueAgg = DB::table('gc_pin_issues')
|
||||||
|
->selectRaw('order_id, COUNT(*) as issued_count')
|
||||||
|
->groupBy('order_id');
|
||||||
|
|
||||||
$qb = DB::table('gc_payment_attempts as a')
|
$qb = DB::table('gc_payment_attempts as a')
|
||||||
->leftJoin('gc_pin_order as o', 'o.id', '=', 'a.order_id')
|
->leftJoin('gc_pin_order as o', 'o.id', '=', 'a.order_id')
|
||||||
->leftJoinSub($oiAgg, 'oi', 'oi.order_id', '=', 'o.id')
|
->leftJoinSub($oiAgg, 'oi', 'oi.order_id', '=', 'o.id')
|
||||||
|
->leftJoinSub($issueAgg, 'gi', 'gi.order_id', '=', 'o.id')
|
||||||
->where('a.mem_no', $memNo)
|
->where('a.mem_no', $memNo)
|
||||||
|
|
||||||
// ✅ 중요: OR 조건 전체를 반드시 하나의 where 그룹으로 묶어야 함
|
// 리스트 기본 노출 대상
|
||||||
->where(function ($s) {
|
->where(function ($s) {
|
||||||
// 1) 취소완료
|
// 1) 취소완료
|
||||||
$s->where(function ($x) {
|
$s->where(function ($x) {
|
||||||
@ -111,6 +159,7 @@ final class UsageRepository
|
|||||||
|
|
||||||
->select([
|
->select([
|
||||||
'a.id as attempt_id',
|
'a.id as attempt_id',
|
||||||
|
'o.id as order_id',
|
||||||
'o.oid as order_oid',
|
'o.oid as order_oid',
|
||||||
DB::raw("COALESCE(o.products_name, oi.first_item_name) as product_name"),
|
DB::raw("COALESCE(o.products_name, oi.first_item_name) as product_name"),
|
||||||
'oi.first_item_name as item_name',
|
'oi.first_item_name as item_name',
|
||||||
@ -121,11 +170,12 @@ final class UsageRepository
|
|||||||
'o.stat_pay as order_stat_pay',
|
'o.stat_pay as order_stat_pay',
|
||||||
'a.cancel_status as attempt_cancel_status',
|
'a.cancel_status as attempt_cancel_status',
|
||||||
'o.cancel_status as order_cancel_status',
|
'o.cancel_status as order_cancel_status',
|
||||||
|
DB::raw("COALESCE(gi.issued_count, 0) as issued_count"),
|
||||||
'a.created_at as created_at',
|
'a.created_at as created_at',
|
||||||
])
|
])
|
||||||
->orderByDesc('a.id');
|
->orderByDesc('a.id');
|
||||||
|
|
||||||
// ✅ q 검색: 거래번호(o.oid / a.oid) 정확히 일치
|
// 주문번호 검색
|
||||||
if ($q !== '') {
|
if ($q !== '') {
|
||||||
$qb->where(function ($w) use ($q) {
|
$qb->where(function ($w) use ($q) {
|
||||||
$w->where('o.oid', $q)
|
$w->where('o.oid', $q)
|
||||||
@ -138,31 +188,63 @@ final class UsageRepository
|
|||||||
$qb->where('a.pay_method', $method);
|
$qb->where('a.pay_method', $method);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 상태 필터도 화면 의미에 맞게 묶어서 처리
|
/**
|
||||||
// (현재 화면 기준: paid / issued / cancel를 의미 상태로 쓰는 경우)
|
* 상태 필터는 화면 의미 기준
|
||||||
|
*
|
||||||
|
* pay : 입금/결제 진행 상태
|
||||||
|
* issue_wait : 결제완료 + 발행대기
|
||||||
|
* issue_done : 결제완료 + 발행완료
|
||||||
|
* cancelled : 결제취소
|
||||||
|
* failed : 결제실패
|
||||||
|
*
|
||||||
|
* 하위호환:
|
||||||
|
* paid : issue_wait + issue_done
|
||||||
|
* issued : pay
|
||||||
|
* cancel : cancelled
|
||||||
|
* canceled : cancelled
|
||||||
|
*/
|
||||||
if ($status !== '') {
|
if ($status !== '') {
|
||||||
if ($status === 'paid') {
|
if (in_array($status, ['cancel', 'cancelled', 'canceled'], true)) {
|
||||||
$qb->where(function ($x) {
|
|
||||||
$x->where('a.status', 'paid')
|
|
||||||
->orWhere('o.stat_pay', 'p');
|
|
||||||
});
|
|
||||||
} elseif ($status === 'issued') {
|
|
||||||
$qb->where(function ($x) {
|
|
||||||
$x->where('a.status', 'issued')
|
|
||||||
->orWhere('o.stat_pay', 'w');
|
|
||||||
});
|
|
||||||
} elseif (in_array($status, ['cancel', 'cancelled', 'canceled'], true)) {
|
|
||||||
$qb->where(function ($x) {
|
$qb->where(function ($x) {
|
||||||
$x->where('a.cancel_status', 'success')
|
$x->where('a.cancel_status', 'success')
|
||||||
->orWhere('o.cancel_status', 'success');
|
->orWhere('o.cancel_status', 'success');
|
||||||
});
|
});
|
||||||
|
} elseif ($status === 'issue_done') {
|
||||||
|
$qb->where(function ($x) {
|
||||||
|
$x->where('a.status', 'paid')
|
||||||
|
->orWhere('o.stat_pay', 'p');
|
||||||
|
});
|
||||||
|
$qb->whereRaw('COALESCE(gi.issued_count, 0) > 0');
|
||||||
|
} elseif ($status === 'issue_wait') {
|
||||||
|
$qb->where(function ($x) {
|
||||||
|
$x->where('a.status', 'paid')
|
||||||
|
->orWhere('o.stat_pay', 'p');
|
||||||
|
});
|
||||||
|
$qb->whereRaw('COALESCE(gi.issued_count, 0) = 0');
|
||||||
|
} elseif ($status === 'pay' || $status === 'issued') {
|
||||||
|
$qb->where(function ($x) {
|
||||||
|
$x->where('a.status', 'issued')
|
||||||
|
->orWhere('o.stat_pay', 'w')
|
||||||
|
->orWhere('a.status', 'ready')
|
||||||
|
->orWhere('a.status', 'redirected');
|
||||||
|
});
|
||||||
|
} elseif ($status === 'paid') {
|
||||||
|
// 하위호환: 결제완료 전체(발행대기+발행완료)
|
||||||
|
$qb->where(function ($x) {
|
||||||
|
$x->where('a.status', 'paid')
|
||||||
|
->orWhere('o.stat_pay', 'p');
|
||||||
|
});
|
||||||
|
} elseif ($status === 'failed') {
|
||||||
|
$qb->where(function ($x) {
|
||||||
|
$x->where('a.status', 'failed')
|
||||||
|
->orWhere('o.stat_pay', 'f');
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// 기타 상태는 attempts 기준으로 그대로
|
|
||||||
$qb->where('a.status', $status);
|
$qb->where('a.status', $status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 날짜 필터 (date 기준)
|
// 날짜 필터
|
||||||
if ($from !== '') {
|
if ($from !== '') {
|
||||||
$qb->whereDate('a.created_at', '>=', $from);
|
$qb->whereDate('a.created_at', '>=', $from);
|
||||||
}
|
}
|
||||||
@ -227,4 +309,81 @@ final class UsageRepository
|
|||||||
|
|
||||||
return array_map(fn($r) => (array)$r, $rows->all());
|
return array_map(fn($r) => (array)$r, $rows->all());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getIssuesForOrder(int $orderId): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('gc_pin_issues')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->orderBy('id', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return array_map(function ($r) {
|
||||||
|
$arr = (array)$r;
|
||||||
|
$arr['pins_json_decoded'] = $this->decodeJsonArray($arr['pins_json'] ?? null);
|
||||||
|
$arr['issue_logs_json_decoded'] = $this->decodeJsonArray($arr['issue_logs_json'] ?? null);
|
||||||
|
return $arr;
|
||||||
|
}, $rows->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIssuesForOrderForUpdate(int $orderId): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('gc_pin_issues')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->orderBy('id', 'asc')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return array_map(fn ($r) => (array)$r, $rows->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 핀번호 확인 / SMS / 출금완료 등 강한 잠금
|
||||||
|
*/
|
||||||
|
public function hasLockedOrOpenedIssues(int $orderId): bool
|
||||||
|
{
|
||||||
|
return DB::table('gc_pin_issues')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNotNull('opened_at')
|
||||||
|
->orWhereNotNull('sms_sent_at')
|
||||||
|
->orWhereNotNull('payout_done_at')
|
||||||
|
->orWhere('cancel_status', 'LOCKED');
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function hasAnyIssuedIssues(int $orderId): bool
|
||||||
|
{
|
||||||
|
return DB::table('gc_pin_issues')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereIn('issue_status', ['PROCESSING', 'ISSUED'])
|
||||||
|
->orWhere('pin_count', '>', 0)
|
||||||
|
->orWhereNotNull('issued_at');
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markIssueOpened(int $issueId, string $openedAt, array $logs, ?string $reason = null): void
|
||||||
|
{
|
||||||
|
DB::table('gc_pin_issues')
|
||||||
|
->where('id', $issueId)
|
||||||
|
->update([
|
||||||
|
'opened_at' => $openedAt,
|
||||||
|
'cancel_status' => 'LOCKED',
|
||||||
|
'cancel_locked_reason' => $reason ?? '핀번호 확인 완료',
|
||||||
|
'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE),
|
||||||
|
'updated_at' => $openedAt,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSkuForOrderItem(int $productId, int $skuId): ?object
|
||||||
|
{
|
||||||
|
return DB::table('gc_product_skus')
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->where('id', $skuId)
|
||||||
|
->where('is_active', 1)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
app/Repositories/Payments/GcPinIssueRepository.php
Normal file
43
app/Repositories/Payments/GcPinIssueRepository.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Payments;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class GcPinIssueRepository
|
||||||
|
{
|
||||||
|
public function findByOrderItemId(int $orderItemId): ?object
|
||||||
|
{
|
||||||
|
return DB::table('gc_pin_issues')
|
||||||
|
->where('order_item_id', $orderItemId)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByOrderId(int $orderId): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('gc_pin_issues')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->orderBy('id', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return array_map(fn ($r) => (array)$r, $rows->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insert(array $data): int
|
||||||
|
{
|
||||||
|
return (int) DB::table('gc_pin_issues')->insertGetId($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markOpened(int $id, string $now, array $logs, ?string $reason = null): void
|
||||||
|
{
|
||||||
|
DB::table('gc_pin_issues')
|
||||||
|
->where('id', $id)
|
||||||
|
->update([
|
||||||
|
'opened_at' => $now,
|
||||||
|
'cancel_status' => 'LOCKED',
|
||||||
|
'cancel_locked_reason' => $reason ?? '핀 오픈 완료',
|
||||||
|
'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE),
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Repositories/Payments/GcPinsRepository.php
Normal file
36
app/Repositories/Payments/GcPinsRepository.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Payments;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class GcPinsRepository
|
||||||
|
{
|
||||||
|
public function lockAvailablePins(int $productId, int $skuId, int $qty): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('gc_pins')
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->where('sku_id', $skuId)
|
||||||
|
->where('status', 'AVAILABLE')
|
||||||
|
->orderBy('id', 'asc')
|
||||||
|
->lockForUpdate()
|
||||||
|
->limit($qty)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return array_map(fn ($r) => (array)$r, $rows->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateStatusByIds(array $ids, string $status, array $extra = []): void
|
||||||
|
{
|
||||||
|
if (empty($ids)) return;
|
||||||
|
|
||||||
|
$payload = array_merge([
|
||||||
|
'status' => $status,
|
||||||
|
'updated_at' => now(),
|
||||||
|
], $extra);
|
||||||
|
|
||||||
|
DB::table('gc_pins')
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->update($payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ final class AdminProductService
|
|||||||
return [
|
return [
|
||||||
'products' => $this->repo->paginateProducts($filters),
|
'products' => $this->repo->paginateProducts($filters),
|
||||||
'categories' => $this->categoryService->getCategoryTree(),
|
'categories' => $this->categoryService->getCategoryTree(),
|
||||||
|
'payments' => DB::table('gc_payment_methods')->where('is_active', 1)->orderBy('sort_order')->get()->toArray(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ final class AdminProductService
|
|||||||
'name' => trim($input['name']),
|
'name' => trim($input['name']),
|
||||||
'thumbnail_media_id' => !empty($input['thumbnail_media_id']) ? (int)$input['thumbnail_media_id'] : null,
|
'thumbnail_media_id' => !empty($input['thumbnail_media_id']) ? (int)$input['thumbnail_media_id'] : null,
|
||||||
'purchase_type' => $input['purchase_type'] ?? 'MULTI_SKU',
|
'purchase_type' => $input['purchase_type'] ?? 'MULTI_SKU',
|
||||||
|
'pin_check_methods' => json_encode($input['pin_check_methods'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||||
'min_buy_qty' => (int)($input['min_buy_qty'] ?? 1),
|
'min_buy_qty' => (int)($input['min_buy_qty'] ?? 1),
|
||||||
'max_buy_qty' => (int)($input['max_buy_qty'] ?? 0),
|
'max_buy_qty' => (int)($input['max_buy_qty'] ?? 0),
|
||||||
'max_buy_amount' => (int)($input['max_buy_amount'] ?? 0),
|
'max_buy_amount' => (int)($input['max_buy_amount'] ?? 0),
|
||||||
@ -119,6 +121,9 @@ final class AdminProductService
|
|||||||
// JSON으로 저장된 결제수단 배열로 복원
|
// JSON으로 저장된 결제수단 배열로 복원
|
||||||
$product->allowed_payments = json_decode($product->allowed_payments ?? '[]', true);
|
$product->allowed_payments = json_decode($product->allowed_payments ?? '[]', true);
|
||||||
|
|
||||||
|
$product->pin_check_methods = json_decode($product->pin_check_methods ?? '[]', true);
|
||||||
|
if (!is_array($product->pin_check_methods)) $product->pin_check_methods = [];
|
||||||
|
|
||||||
// 썸네일 정보 가져오기
|
// 썸네일 정보 가져오기
|
||||||
$thumbnail = null;
|
$thumbnail = null;
|
||||||
if ($product->thumbnail_media_id) {
|
if ($product->thumbnail_media_id) {
|
||||||
@ -147,6 +152,7 @@ final class AdminProductService
|
|||||||
'name' => trim($input['name']),
|
'name' => trim($input['name']),
|
||||||
'thumbnail_media_id' => !empty($input['thumbnail_media_id']) ? (int)$input['thumbnail_media_id'] : null,
|
'thumbnail_media_id' => !empty($input['thumbnail_media_id']) ? (int)$input['thumbnail_media_id'] : null,
|
||||||
'purchase_type' => $input['purchase_type'] ?? 'MULTI_SKU',
|
'purchase_type' => $input['purchase_type'] ?? 'MULTI_SKU',
|
||||||
|
'pin_check_methods' => json_encode($input['pin_check_methods'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||||
'min_buy_qty' => (int)($input['min_buy_qty'] ?? 1),
|
'min_buy_qty' => (int)($input['min_buy_qty'] ?? 1),
|
||||||
'max_buy_qty' => (int)($input['max_buy_qty'] ?? 0),
|
'max_buy_qty' => (int)($input['max_buy_qty'] ?? 0),
|
||||||
'max_buy_amount' => (int)($input['max_buy_amount'] ?? 0),
|
'max_buy_amount' => (int)($input['max_buy_amount'] ?? 0),
|
||||||
|
|||||||
@ -13,9 +13,6 @@ final class AdminSaleCodeService
|
|||||||
private readonly AdminAuditService $audit,
|
private readonly AdminAuditService $audit,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* 화면 출력용 트리 구조 생성 (연동사 -> 하위 상품코드)
|
|
||||||
*/
|
|
||||||
public function getGroupedTree(): array
|
public function getGroupedTree(): array
|
||||||
{
|
{
|
||||||
$providers = $this->repo->getAllProviders();
|
$providers = $this->repo->getAllProviders();
|
||||||
@ -36,23 +33,19 @@ final class AdminSaleCodeService
|
|||||||
return array_values($tree);
|
return array_values($tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Provider ---
|
|
||||||
public function storeProvider(array $input, int $actorAdminId, string $ip, string $ua): array
|
public function storeProvider(array $input, int $actorAdminId, string $ip, string $ua): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) {
|
return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) {
|
||||||
$data = [
|
$data = $this->buildProviderPayload($input, true);
|
||||||
'code' => strtoupper(trim($input['code'])),
|
|
||||||
'name' => trim($input['name']),
|
|
||||||
'is_active' => (int)$input['is_active'],
|
|
||||||
'sort_order' => 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
$newId = $this->repo->insertProvider($data);
|
$newId = $this->repo->insertProvider($data);
|
||||||
$this->audit->log($actorAdminId, 'admin.sale_code.provider.create', 'api_provider', $newId, null, $data, $ip, $ua);
|
$this->audit->log($actorAdminId, 'admin.sale_code.provider.create', 'api_provider', $newId, null, $data, $ip, $ua);
|
||||||
|
|
||||||
return ['ok' => true, 'message' => '연동사가 등록되었습니다.'];
|
return ['ok' => true, 'message' => '연동사가 등록되었습니다.'];
|
||||||
});
|
});
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return ['ok' => false, 'message' => $e->getMessage()];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ['ok' => false, 'message' => '연동사 코드가 중복되거나 저장 오류가 발생했습니다.'];
|
return ['ok' => false, 'message' => '연동사 코드가 중복되거나 저장 오류가 발생했습니다.'];
|
||||||
}
|
}
|
||||||
@ -63,18 +56,29 @@ final class AdminSaleCodeService
|
|||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) {
|
return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) {
|
||||||
$before = $this->repo->findProvider($id);
|
$before = $this->repo->findProvider($id);
|
||||||
if (!$before) return ['ok' => false, 'message' => '연동사를 찾을 수 없습니다.'];
|
if (!$before) {
|
||||||
|
return ['ok' => false, 'message' => '연동사를 찾을 수 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
$data = [
|
$input['code'] = (string) $before->code; // 수정 시 code 고정
|
||||||
'name' => trim($input['name']),
|
$data = $this->buildProviderPayload($input, false);
|
||||||
'is_active' => (int)$input['is_active'],
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->repo->updateProvider($id, $data);
|
$this->repo->updateProvider($id, $data);
|
||||||
$this->audit->log($actorAdminId, 'admin.sale_code.provider.update', 'api_provider', $id, (array)$before, array_merge((array)$before, $data), $ip, $ua);
|
$this->audit->log(
|
||||||
|
$actorAdminId,
|
||||||
|
'admin.sale_code.provider.update',
|
||||||
|
'api_provider',
|
||||||
|
$id,
|
||||||
|
(array) $before,
|
||||||
|
array_merge((array) $before, $data),
|
||||||
|
$ip,
|
||||||
|
$ua
|
||||||
|
);
|
||||||
|
|
||||||
return ['ok' => true, 'message' => '연동사가 수정되었습니다.'];
|
return ['ok' => true, 'message' => '연동사가 수정되었습니다.'];
|
||||||
});
|
});
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return ['ok' => false, 'message' => $e->getMessage()];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다.'];
|
return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다.'];
|
||||||
}
|
}
|
||||||
@ -95,7 +99,6 @@ final class AdminSaleCodeService
|
|||||||
return ['ok' => true, 'message' => '삭제되었습니다.'];
|
return ['ok' => true, 'message' => '삭제되었습니다.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Product Code ---
|
|
||||||
public function storeCode(array $input, int $actorAdminId, string $ip, string $ua): array
|
public function storeCode(array $input, int $actorAdminId, string $ip, string $ua): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@ -166,4 +169,86 @@ final class AdminSaleCodeService
|
|||||||
|
|
||||||
return ['ok' => true, 'message' => '상품 코드가 삭제되었습니다.'];
|
return ['ok' => true, 'message' => '상품 코드가 삭제되었습니다.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildProviderPayload(array $input, bool $isCreate): array
|
||||||
|
{
|
||||||
|
$code = strtoupper(trim((string) ($input['code'] ?? '')));
|
||||||
|
$transportType = strtoupper(trim((string) ($input['transport_type'] ?? '')));
|
||||||
|
$baseUrl = $this->nullIfBlank($input['base_url'] ?? null);
|
||||||
|
$host = $this->nullIfBlank($input['host'] ?? null);
|
||||||
|
$port = isset($input['port']) && $input['port'] !== '' ? (int) $input['port'] : null;
|
||||||
|
|
||||||
|
$configJson = $this->normalizeConfigJson($input['config_json'] ?? null, $code);
|
||||||
|
|
||||||
|
if ($transportType === 'TCP_SOCKET') {
|
||||||
|
if ($host === null || $port === null) {
|
||||||
|
throw new \InvalidArgumentException('TCP_SOCKET 방식은 host, port를 반드시 입력해야 합니다.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($baseUrl === null) {
|
||||||
|
throw new \InvalidArgumentException('HTTP 방식은 base_url을 반드시 입력해야 합니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge(
|
||||||
|
$isCreate ? ['code' => $code] : [],
|
||||||
|
[
|
||||||
|
'name' => trim((string) $input['name']),
|
||||||
|
'transport_type' => $transportType,
|
||||||
|
'base_url' => $baseUrl,
|
||||||
|
'host' => $host,
|
||||||
|
'port' => $port,
|
||||||
|
'timeout_connect_sec' => (int) $input['timeout_connect_sec'],
|
||||||
|
'timeout_read_sec' => (int) $input['timeout_read_sec'],
|
||||||
|
'charset' => strtoupper(trim((string) $input['charset'])),
|
||||||
|
'response_format_default' => $this->nullIfBlank($input['response_format_default'] ?? null),
|
||||||
|
'is_test_mode' => (int) $input['is_test_mode'],
|
||||||
|
'supports_issue' => (int) $input['supports_issue'],
|
||||||
|
'supports_cancel' => (int) $input['supports_cancel'],
|
||||||
|
'supports_resend' => (int) $input['supports_resend'],
|
||||||
|
'supports_cancel_check' => (int) $input['supports_cancel_check'],
|
||||||
|
'supports_network_cancel' => (int) $input['supports_network_cancel'],
|
||||||
|
'config_json' => $configJson,
|
||||||
|
'is_active' => (int) $input['is_active'],
|
||||||
|
'sort_order' => 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeConfigJson(?string $json, string $providerCode): ?string
|
||||||
|
{
|
||||||
|
$json = $this->nullIfBlank($json);
|
||||||
|
if ($json === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new \InvalidArgumentException('상세 설정 JSON 형식이 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->requiredConfigKeys($providerCode) as $requiredKey) {
|
||||||
|
if (!array_key_exists($requiredKey, $decoded)) {
|
||||||
|
throw new \InvalidArgumentException("상세 설정 JSON에 필수 키가 없습니다: {$requiredKey}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requiredConfigKeys(string $providerCode): array
|
||||||
|
{
|
||||||
|
return match ($providerCode) {
|
||||||
|
'DANAL' => ['cp_id', 'cp_pwd_enc', 'subcpid', 'crypto_key_enc', 'crypto_iv_enc'],
|
||||||
|
'KORCULTURE' => ['member_code', 'sub_member_code', 'issue_tr_code', 'cancel_tr_code'],
|
||||||
|
'KPREPAID' => ['chain_code', 'issue_path', 'cancel_path'],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullIfBlank(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$value = is_string($value) ? trim($value) : $value;
|
||||||
|
return ($value === '' || $value === null) ? null : (string) $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,9 @@ namespace App\Services\Mypage;
|
|||||||
|
|
||||||
use App\Repositories\Mypage\UsageRepository;
|
use App\Repositories\Mypage\UsageRepository;
|
||||||
use App\Repositories\Payments\GcPinOrderRepository;
|
use App\Repositories\Payments\GcPinOrderRepository;
|
||||||
|
use App\Repositories\Member\MemberAuthRepository;
|
||||||
use App\Services\Payments\PaymentCancelService;
|
use App\Services\Payments\PaymentCancelService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
final class UsageService
|
final class UsageService
|
||||||
{
|
{
|
||||||
@ -12,6 +14,7 @@ final class UsageService
|
|||||||
private readonly UsageRepository $repo,
|
private readonly UsageRepository $repo,
|
||||||
private readonly GcPinOrderRepository $orders,
|
private readonly GcPinOrderRepository $orders,
|
||||||
private readonly PaymentCancelService $cancelSvc,
|
private readonly PaymentCancelService $cancelSvc,
|
||||||
|
private readonly MemberAuthRepository $memberAuthRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,7 +22,7 @@ final class UsageService
|
|||||||
*/
|
*/
|
||||||
public function buildListPageData(int $sessionMemNo, array $filters): array
|
public function buildListPageData(int $sessionMemNo, array $filters): array
|
||||||
{
|
{
|
||||||
$rows = $this->repo->paginateAttemptsWithOrder($sessionMemNo, $filters, 5);
|
$rows = $this->repo->paginateAttemptsWithOrder($sessionMemNo, $filters, 15);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pageTitle' => '구매내역',
|
'pageTitle' => '구매내역',
|
||||||
@ -43,81 +46,215 @@ final class UsageService
|
|||||||
$order = (array)($data['order'] ?? []);
|
$order = (array)($data['order'] ?? []);
|
||||||
$orderId = (int)($order['id'] ?? 0);
|
$orderId = (int)($order['id'] ?? 0);
|
||||||
|
|
||||||
$pins = $this->repo->getPinsForOrder($orderId);
|
$issues = $this->repo->getIssuesForOrder($orderId);
|
||||||
$cancelLogs = $this->repo->getCancelLogsForAttempt($attemptId, 20);
|
$cancelLogs = $this->repo->getCancelLogsForAttempt($attemptId, 20);
|
||||||
|
|
||||||
$retData = $order['ret_data'] ?? null;
|
$pins = [];
|
||||||
$retArr = is_array($retData) ? $retData : [];
|
foreach ($issues as $issue) {
|
||||||
$pinsOpened = !empty($retArr['pin_opened_at']);
|
$issue = (array)$issue;
|
||||||
|
|
||||||
|
$orderItemId = (int)($issue['order_item_id'] ?? 0);
|
||||||
|
$pinsJson = $issue['pins_json_decoded'] ?? null;
|
||||||
|
|
||||||
|
if (!is_array($pinsJson)) {
|
||||||
|
$pinsJson = $this->decodeJsonArray($issue['pins_json'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pinsJson as $pin) {
|
||||||
|
$pin = (array)$pin;
|
||||||
|
|
||||||
|
$pins[] = [
|
||||||
|
'id' => (int)($pin['gc_pin_id'] ?? 0),
|
||||||
|
'issue_id' => (int)($issue['id'] ?? 0),
|
||||||
|
'order_item_id' => $orderItemId,
|
||||||
|
'status' => (string)($issue['issue_status'] ?? ''),
|
||||||
|
'pin_mask' => (string)($pin['pin_mask'] ?? ''),
|
||||||
|
'pin' => $this->decryptIssuedPin(
|
||||||
|
(string)($pin['pin_enc'] ?? ''),
|
||||||
|
(int)($order['mem_no'] ?? 0),
|
||||||
|
(string)($order['oid'] ?? ''),
|
||||||
|
$orderItemId,
|
||||||
|
(int)($pin['seq'] ?? 0)
|
||||||
|
),
|
||||||
|
'face_value' => (int)($pin['face_value'] ?? 0),
|
||||||
|
'issued_at' => (string)($pin['issued_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이번 요청에서만 실핀 표시
|
||||||
|
$pinsRevealed = (
|
||||||
|
(int)request()->query('revealed', 0) === 1
|
||||||
|
|| (int)session('pin_revealed_attempt_id', 0) === $attemptId
|
||||||
|
);
|
||||||
|
|
||||||
// 결제완료 조건
|
// 결제완료 조건
|
||||||
$attempt = (array)($data['attempt'] ?? []);
|
$attempt = (array)($data['attempt'] ?? []);
|
||||||
$isPaid = (($order['stat_pay'] ?? '') === 'p') || (($attempt['status'] ?? '') === 'paid');
|
$isPaid = (($order['stat_pay'] ?? '') === 'p') || (($attempt['status'] ?? '') === 'paid');
|
||||||
|
|
||||||
// cancel_status 기반 버튼 제어
|
// 핀 발행 완료되면 회원 취소 불가
|
||||||
$aCancel = (string)($attempt['cancel_status'] ?? 'none');
|
$hasIssuedIssues = !empty($issues);
|
||||||
$canCancel = $isPaid && !$pinsOpened && in_array($aCancel, ['none','failed'], true);
|
|
||||||
|
|
||||||
|
$aCancel = (string)($attempt['cancel_status'] ?? 'none');
|
||||||
|
$canCancel = $isPaid
|
||||||
|
&& !$hasIssuedIssues
|
||||||
|
&& in_array($aCancel, ['none', 'failed'], true);
|
||||||
|
|
||||||
|
$data['issues'] = $issues;
|
||||||
$data['pins'] = $pins;
|
$data['pins'] = $pins;
|
||||||
$data['pinsOpened'] = $pinsOpened;
|
$data['pinsRevealed'] = $pinsRevealed;
|
||||||
|
$data['hasIssuedIssues'] = $hasIssuedIssues;
|
||||||
$data['canCancel'] = $canCancel;
|
$data['canCancel'] = $canCancel;
|
||||||
$data['cancelLogs'] = $cancelLogs;
|
$data['cancelLogs'] = $cancelLogs;
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function decodeJsonArray($json): array
|
||||||
|
{
|
||||||
|
if (is_array($json)) {
|
||||||
|
return $json;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = trim((string)$json);
|
||||||
|
if ($json === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decryptIssuedPin(string $enc, int $memNo, string $oid, int $orderItemId, int $seq): string
|
||||||
|
{
|
||||||
|
if (trim($enc) === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$plain = \Illuminate\Support\Facades\Crypt::decryptString($enc);
|
||||||
|
|
||||||
|
$suffix = '|M:' . $memNo . '|O:' . $oid . '|I:' . $orderItemId . '|S:' . $seq;
|
||||||
|
|
||||||
|
if (str_ends_with($plain, $suffix)) {
|
||||||
|
return substr($plain, 0, -strlen($suffix));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 핀 오픈(확인): ret_data에 pin_opened_at 기록
|
* 핀 오픈(확인): ret_data에 pin_opened_at 기록
|
||||||
*/
|
*/
|
||||||
public function openPins(int $attemptId, int $sessionMemNo): array
|
public function openPins(int $attemptId, int $sessionMemNo): array
|
||||||
{
|
{
|
||||||
|
return \Illuminate\Support\Facades\DB::transaction(function () use ($attemptId, $sessionMemNo) {
|
||||||
$row = $this->repo->findAttemptWithOrder($attemptId);
|
$row = $this->repo->findAttemptWithOrder($attemptId);
|
||||||
if (!$row) return ['ok'=>false, 'message'=>'결제내역을 찾을 수 없습니다.'];
|
if (!$row) {
|
||||||
|
return ['ok' => false, 'message' => '결제내역을 찾을 수 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
$attemptMem = (int)($row->attempt_mem_no ?? 0);
|
$attemptMem = (int)($row->attempt_mem_no ?? 0);
|
||||||
$orderMem = (int)($row->order_mem_no ?? 0);
|
$orderMem = (int)($row->order_mem_no ?? 0);
|
||||||
|
|
||||||
if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) {
|
if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) {
|
||||||
return ['ok' => false, 'message' => '권한이 없습니다.'];
|
return ['ok' => false, 'message' => '권한이 없습니다.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$attemptStatus = (string)($row->attempt_status ?? '');
|
$attemptStatus = (string)($row->attempt_status ?? '');
|
||||||
$orderStatPay = (string)($row->order_stat_pay ?? '');
|
$orderStatPay = (string)($row->order_stat_pay ?? '');
|
||||||
|
|
||||||
if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) {
|
if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) {
|
||||||
return ['ok' => false, 'message' => '결제완료 상태에서만 핀 확인이 가능합니다.'];
|
return ['ok' => false, 'message' => '결제완료 상태에서만 핀 확인이 가능합니다.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$oid = (string)($row->order_oid ?? '');
|
$orderId = (int)($row->order_id ?? 0);
|
||||||
if ($oid === '') return ['ok'=>false, 'message'=>'주문정보가 올바르지 않습니다.'];
|
if ($orderId <= 0) {
|
||||||
|
return ['ok' => false, 'message' => '주문정보가 올바르지 않습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
$order = $this->orders->findByOidForUpdate($oid);
|
// 발행건 잠금 조회
|
||||||
if (!$order) return ['ok'=>false, 'message'=>'주문을 찾을 수 없습니다.'];
|
$issues = $this->repo->getIssuesForOrderForUpdate($orderId);
|
||||||
|
if (empty($issues)) {
|
||||||
|
return ['ok' => false, 'message' => '발행된 핀이 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
$ret = (array)($order->ret_data ?? []);
|
// 실제 발행된 건만 대상
|
||||||
if (!empty($ret['pin_opened_at'])) {
|
$issuedIssues = array_values(array_filter($issues, function ($issue) {
|
||||||
|
$issue = (array)$issue;
|
||||||
|
|
||||||
|
$status = (string)($issue['issue_status'] ?? '');
|
||||||
|
$pinCnt = (int)($issue['pin_count'] ?? 0);
|
||||||
|
|
||||||
|
return in_array($status, ['ISSUED', 'PROCESSING'], true) || $pinCnt > 0;
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (empty($issuedIssues)) {
|
||||||
|
return ['ok' => false, 'message' => '확인 가능한 핀이 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 모두 오픈된 상태면 성공 처리
|
||||||
|
$allOpened = collect($issuedIssues)->every(function ($issue) {
|
||||||
|
$issue = (array)$issue;
|
||||||
|
return !empty($issue['opened_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($allOpened) {
|
||||||
return ['ok' => true];
|
return ['ok' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
$ret['pin_opened_at'] = now()->toDateTimeString();
|
$now = now()->format('Y-m-d H:i:s');
|
||||||
$order->ret_data = $ret;
|
|
||||||
$order->save();
|
foreach ($issuedIssues as $issue) {
|
||||||
|
$issue = (array)$issue;
|
||||||
|
|
||||||
|
if (!empty($issue['opened_at'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = $this->decodeJsonArray($issue['issue_logs_json'] ?? null);
|
||||||
|
$logs[] = [
|
||||||
|
'at' => $now,
|
||||||
|
'type' => 'OPEN',
|
||||||
|
'code' => 'PIN_OPENED',
|
||||||
|
'msg' => '회원 웹페이지에서 핀 오픈',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repo->markIssueOpened(
|
||||||
|
(int)$issue['id'],
|
||||||
|
$now,
|
||||||
|
$logs,
|
||||||
|
'핀 오픈 완료'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ['ok' => true];
|
return ['ok' => true];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 결제완료 후 취소(핀 오픈 전만)
|
* 결제완료 후 취소(핀 오픈 전만)
|
||||||
*/
|
*/
|
||||||
public function cancelPaidAttempt(int $attemptId, int $sessionMemNo, string $reason): array
|
public function cancelPaidAttempt(int $attemptId, int $sessionMemNo, string $reason): array
|
||||||
{
|
{
|
||||||
$data = $this->buildDetailPageData($attemptId, $sessionMemNo);
|
$data = $this->buildDetailPageData($attemptId, $sessionMemNo);
|
||||||
|
$order = (array)($data['order'] ?? []);
|
||||||
|
$orderId = (int)($order['id'] ?? 0);
|
||||||
|
|
||||||
$pinsOpened = (bool)($data['pinsOpened'] ?? false);
|
// 핀 발행이 완료되면 회원 취소 금지
|
||||||
|
if ($orderId > 0 && $this->repo->hasAnyIssuedIssues($orderId)) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => '핀 발행이 완료된 주문은 회원이 직접 취소할 수 없습니다. 관리자에게 문의해 주세요.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return $this->cancelSvc->cancelByAttempt(
|
return $this->cancelSvc->cancelByAttempt(
|
||||||
$attemptId,
|
$attemptId,
|
||||||
['type' => 'user', 'mem_no' => $sessionMemNo, 'id' => $sessionMemNo],
|
['type' => 'user', 'mem_no' => $sessionMemNo, 'id' => $sessionMemNo],
|
||||||
$reason,
|
$reason,
|
||||||
$pinsOpened
|
false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,6 +285,11 @@ final class UsageService
|
|||||||
|
|
||||||
$items = $this->repo->getOrderItems($orderId);
|
$items = $this->repo->getOrderItems($orderId);
|
||||||
|
|
||||||
|
// ✅ 핀 발행 선택(핀확인방법): 상품 설정(gc_products.pin_check_methods) 기반
|
||||||
|
// - 주문에 여러 상품이 섞였을 수 있으므로 "공통으로 가능한 방식"(교집합)만 허용
|
||||||
|
$orderProductId = (int)($row->order_product_id ?? 0);
|
||||||
|
$issue = $this->resolveIssueOptions($orderProductId, $items);
|
||||||
|
|
||||||
$requiredQty = 0;
|
$requiredQty = 0;
|
||||||
foreach ($items as $it) $requiredQty += (int)($it->qty ?? 0);
|
foreach ($items as $it) $requiredQty += (int)($it->qty ?? 0);
|
||||||
|
|
||||||
@ -170,6 +312,10 @@ final class UsageService
|
|||||||
'items' => $this->itemsViewModel($items),
|
'items' => $this->itemsViewModel($items),
|
||||||
'productname' => $row->order_product_name,
|
'productname' => $row->order_product_name,
|
||||||
|
|
||||||
|
// 핀 발행 선택 UI 제어용
|
||||||
|
'issueMethods' => $issue['methods'],
|
||||||
|
'issueMissing' => $issue['missing'],
|
||||||
|
|
||||||
'requiredQty' => $requiredQty,
|
'requiredQty' => $requiredQty,
|
||||||
'assignedPinsCount' => $assignedPinsCount,
|
'assignedPinsCount' => $assignedPinsCount,
|
||||||
'pinsSummary' => $pinsSummary,
|
'pinsSummary' => $pinsSummary,
|
||||||
@ -282,6 +428,106 @@ final class UsageService
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주문 아이템에서 상품ID 후보를 추출하고, 상품 설정(pin_check_methods)에 따라
|
||||||
|
* 사용자 페이지의 "핀 발행 선택" 옵션을 계산한다.
|
||||||
|
*
|
||||||
|
* - 여러 상품이 한 주문에 섞일 수 있으므로 교집합만 허용
|
||||||
|
* - 설정이 비어있거나 조회 실패 시: 기존 UI 깨짐 방지를 위해 3개 모두 허용
|
||||||
|
*/
|
||||||
|
private function resolveIssueOptions(int $orderProductId, $items): array
|
||||||
|
{
|
||||||
|
$all = ['PIN_INSTANT', 'SMS', 'BUYBACK'];
|
||||||
|
|
||||||
|
// ✅ 주문이 단일 상품 구조면 order.product_id를 우선 사용
|
||||||
|
$productIds = [];
|
||||||
|
if ($orderProductId > 0) {
|
||||||
|
$productIds = [$orderProductId];
|
||||||
|
} else {
|
||||||
|
// (구조가 바뀌거나 다상품 주문일 수 있는 경우) 아이템에서 추출
|
||||||
|
$productIds = $this->extractProductIdsFromOrderItems($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
$opts = $this->repo->getProductsIssueOptions($productIds);
|
||||||
|
if (empty($opts)) {
|
||||||
|
// 조회 실패/미설정 시 UI 깨짐 방지: 3개 모두 허용
|
||||||
|
return ['methods' => $all, 'missing' => [], 'product_ids' => $productIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
$intersection = null;
|
||||||
|
foreach ($opts as $pid => $opt) {
|
||||||
|
$m = $this->normalizeIssueMethods($opt);
|
||||||
|
|
||||||
|
$intersection = ($intersection === null)
|
||||||
|
? $m
|
||||||
|
: array_values(array_intersect($intersection, $m));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($intersection)) $intersection = ['PIN_INSTANT'];
|
||||||
|
|
||||||
|
// 표시 순서 고정
|
||||||
|
$ordered = array_values(array_filter($all, fn($k) => in_array($k, $intersection, true)));
|
||||||
|
$missing = array_values(array_diff($all, $ordered));
|
||||||
|
|
||||||
|
return ['methods' => $ordered, 'missing' => $missing, 'product_ids' => array_map('intval', array_keys($opts))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 1개의 옵션을 "표시용 methods"로 정규화
|
||||||
|
*
|
||||||
|
* - pin_check_methods가 명시적으로 설정된 경우: 그 값을 그대로 신뢰 (BUYBACK 포함/제외도 여기서 결정)
|
||||||
|
* - pin_check_methods가 아직 비어있는(레거시) 경우: 기본은 PIN_INSTANT+SMS, BUYBACK은 is_buyback_allowed로만 노출
|
||||||
|
*/
|
||||||
|
private function normalizeIssueMethods(array $opt): array
|
||||||
|
{
|
||||||
|
$explicit = !empty($opt['has_pin_check_methods']);
|
||||||
|
|
||||||
|
$m = $opt['pin_check_methods'] ?? [];
|
||||||
|
if (!is_array($m)) $m = [];
|
||||||
|
$m = array_values(array_unique(array_filter(array_map('strval', $m))));
|
||||||
|
|
||||||
|
if ($explicit) {
|
||||||
|
// 명시 설정인데 비어있으면 안전하게 즉시확인만
|
||||||
|
if (empty($m)) return ['PIN_INSTANT'];
|
||||||
|
return $m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레거시: 기본 2개 + 매입은 기존 플래그로만
|
||||||
|
$out = ['PIN_INSTANT', 'SMS'];
|
||||||
|
if (!empty($opt['is_buyback_allowed'])) $out[] = 'BUYBACK';
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractProductIdsFromOrderItems($items): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
|
||||||
|
foreach ($items as $it) {
|
||||||
|
$pid = null;
|
||||||
|
|
||||||
|
// 1) 컬럼이 존재하는 경우 (gc_pin_order_items.product_id 등)
|
||||||
|
if (is_object($it) && property_exists($it, 'product_id') && is_numeric($it->product_id)) {
|
||||||
|
$pid = (int)$it->product_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) meta(JSON) 안에 들어있는 경우
|
||||||
|
if (!$pid) {
|
||||||
|
$meta = $this->jsonDecodeOrArray($it->meta ?? null);
|
||||||
|
foreach (['product_id', 'gc_product_id', 'productId', 'pid', 'product'] as $k) {
|
||||||
|
if (isset($meta[$k]) && is_numeric($meta[$k])) {
|
||||||
|
$pid = (int)$meta[$k];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pid && $pid > 0) $ids[] = $pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_values(array_unique(array_filter($ids, fn($n) => $n > 0)));
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
private function extractVactInfo(object $row): array
|
private function extractVactInfo(object $row): array
|
||||||
{
|
{
|
||||||
$candidates = [
|
$candidates = [
|
||||||
@ -336,4 +582,47 @@ final class UsageService
|
|||||||
|
|
||||||
return $s;
|
return $s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function revealPins(int $attemptId, int $sessionMemNo, string $pin2): array
|
||||||
|
{
|
||||||
|
$row = $this->repo->findAttemptWithOrder($attemptId);
|
||||||
|
if (!$row) {
|
||||||
|
return ['ok' => false, 'message' => '결제내역을 찾을 수 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attemptMem = (int)($row->attempt_mem_no ?? 0);
|
||||||
|
$orderMem = (int)($row->order_mem_no ?? 0);
|
||||||
|
|
||||||
|
if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) {
|
||||||
|
return ['ok' => false, 'message' => '권한이 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attemptStatus = (string)($row->attempt_status ?? '');
|
||||||
|
$orderStatPay = (string)($row->order_stat_pay ?? '');
|
||||||
|
|
||||||
|
if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) {
|
||||||
|
return ['ok' => false, 'message' => '결제완료 상태에서만 핀번호 확인이 가능합니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pin2Ok = $this->memberAuthRepo->verifyPin2($sessionMemNo, $pin2);
|
||||||
|
if (!$pin2Ok) {
|
||||||
|
return ['ok' => false, 'message' => '2차 비밀번호가 올바르지 않습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderId = (int)($row->order_id ?? 0);
|
||||||
|
if ($orderId <= 0) {
|
||||||
|
return ['ok' => false, 'message' => '주문정보가 올바르지 않습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues = $this->repo->getIssuesForOrder($orderId);
|
||||||
|
if (empty($issues)) {
|
||||||
|
return ['ok' => false, 'message' => '발행된 핀이 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태값 변경 없이 이번 요청에서만 실핀 표시
|
||||||
|
session()->flash('pin_revealed_attempt_id', $attemptId);
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
437
app/Services/Payments/OwnPinIssueService.php
Normal file
437
app/Services/Payments/OwnPinIssueService.php
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Payments;
|
||||||
|
|
||||||
|
use App\Repositories\Mypage\UsageRepository;
|
||||||
|
use App\Repositories\Payments\GcPinIssueRepository;
|
||||||
|
use App\Repositories\Payments\GcPinsRepository;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class OwnPinIssueService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UsageRepository $usageRepo,
|
||||||
|
private readonly GcPinIssueRepository $issueRepo,
|
||||||
|
private readonly GcPinsRepository $pinsRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹페이지 직접발행 실행
|
||||||
|
* - 주문에는 OWN_PIN 또는 API_LINK 중 한 종류만 존재한다고 가정
|
||||||
|
* - OWN_PIN : 실제 자사핀 발행
|
||||||
|
* - API_LINK : 현재는 분기만 처리하고 미구현 메시지 반환
|
||||||
|
*/
|
||||||
|
public function issuePinInstant(int $attemptId, int $sessionMemNo): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return DB::transaction(function () use ($attemptId, $sessionMemNo) {
|
||||||
|
$row = $this->usageRepo->findAttemptWithOrder($attemptId);
|
||||||
|
if (!$row) {
|
||||||
|
return ['ok' => false, 'message' => '결제내역을 찾을 수 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attemptMem = (int)($row->attempt_mem_no ?? 0);
|
||||||
|
$orderMem = (int)($row->order_mem_no ?? 0);
|
||||||
|
|
||||||
|
if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) {
|
||||||
|
return ['ok' => false, 'message' => '권한이 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderId = (int)($row->order_id ?? 0);
|
||||||
|
if ($orderId <= 0) {
|
||||||
|
return ['ok' => false, 'message' => '주문 정보가 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$isPaid = (($row->order_stat_pay ?? '') === 'p') || (($row->attempt_status ?? '') === 'paid');
|
||||||
|
if (!$isPaid) {
|
||||||
|
return ['ok' => false, 'message' => '결제 완료 주문만 발행할 수 있습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $this->usageRepo->getOrderItems($orderId);
|
||||||
|
if ($items->isEmpty()) {
|
||||||
|
return ['ok' => false, 'message' => '주문 아이템이 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now()->format('Y-m-d H:i:s');
|
||||||
|
$created = 0;
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$orderItemId = (int)($item->id ?? 0);
|
||||||
|
if ($orderItemId <= 0) {
|
||||||
|
return ['ok' => false, 'message' => '주문 아이템 정보가 올바르지 않습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 발행된 주문아이템이면 중복 방지
|
||||||
|
$exists = $this->issueRepo->findByOrderItemId($orderItemId);
|
||||||
|
if ($exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $this->decodeJson((string)($item->meta ?? ''));
|
||||||
|
$productId = (int)($meta['product_id'] ?? 0);
|
||||||
|
|
||||||
|
// 정책상 item_code = gc_product_skus.id
|
||||||
|
$skuId = (int)($item->item_code ?? ($meta['sku_id'] ?? 0));
|
||||||
|
|
||||||
|
if ($productId <= 0 || $skuId <= 0) {
|
||||||
|
return ['ok' => false, 'message' => '상품 정보(product_id/sku_id)가 올바르지 않습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$qty = (int)($item->qty ?? 0);
|
||||||
|
if ($qty <= 0) {
|
||||||
|
return ['ok' => false, 'message' => '주문 수량이 올바르지 않습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상품의 회원 발행 방식 허용 여부 확인
|
||||||
|
$issueOptions = $this->usageRepo->getProductsIssueOptions([$productId]);
|
||||||
|
$methods = $issueOptions[$productId]['pin_check_methods'] ?? [];
|
||||||
|
if (!in_array('PIN_INSTANT', $methods, true)) {
|
||||||
|
return ['ok' => false, 'message' => '웹페이지 직접발행이 허용되지 않은 상품입니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// SKU 조회 -> sales_method 분기
|
||||||
|
$sku = $this->usageRepo->getSkuForOrderItem($productId, $skuId);
|
||||||
|
if (!$sku) {
|
||||||
|
return ['ok' => false, 'message' => 'SKU 정보를 찾을 수 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$salesMethod = (string)($sku->sales_method ?? 'OWN_PIN');
|
||||||
|
$apiProviderId = (int)($sku->api_provider_id ?? 0);
|
||||||
|
$apiProductCode = trim((string)($sku->api_product_code ?? ''));
|
||||||
|
|
||||||
|
if ($salesMethod === 'OWN_PIN') {
|
||||||
|
$this->issueOwnPinItem(
|
||||||
|
row: $row,
|
||||||
|
item: (array)$item,
|
||||||
|
attemptId: $attemptId,
|
||||||
|
orderId: $orderId,
|
||||||
|
orderItemId: $orderItemId,
|
||||||
|
productId: $productId,
|
||||||
|
skuId: $skuId,
|
||||||
|
qty: $qty,
|
||||||
|
now: $now
|
||||||
|
);
|
||||||
|
|
||||||
|
$created++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($salesMethod === 'API_LINK') {
|
||||||
|
$this->issueApiLinkPlaceholder(
|
||||||
|
row: $row,
|
||||||
|
item: (array)$item,
|
||||||
|
attemptId: $attemptId,
|
||||||
|
orderId: $orderId,
|
||||||
|
orderItemId: $orderItemId,
|
||||||
|
productId: $productId,
|
||||||
|
skuId: $skuId,
|
||||||
|
qty: $qty,
|
||||||
|
apiProviderId: $apiProviderId,
|
||||||
|
apiProductCode: $apiProductCode,
|
||||||
|
now: $now
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => '연동발행 상품입니다. 연동발행 구현 후 처리됩니다.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => false, 'message' => '알 수 없는 발행 방식입니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($created <= 0) {
|
||||||
|
return ['ok' => true, 'message' => '이미 발행된 주문입니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'message' => '자사핀 발행이 완료되었습니다.'];
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $e->getMessage() !== '' ? $e->getMessage() : '핀 발행 중 오류가 발생했습니다.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OWN_PIN 실제 발행
|
||||||
|
*/
|
||||||
|
private function issueOwnPinItem(
|
||||||
|
object|array $row,
|
||||||
|
array $item,
|
||||||
|
int $attemptId,
|
||||||
|
int $orderId,
|
||||||
|
int $orderItemId,
|
||||||
|
int $productId,
|
||||||
|
int $skuId,
|
||||||
|
int $qty,
|
||||||
|
string $now,
|
||||||
|
): void {
|
||||||
|
$row = (object)$row;
|
||||||
|
|
||||||
|
$pins = $this->pinsRepo->lockAvailablePins($productId, $skuId, $qty);
|
||||||
|
if (count($pins) < $qty) {
|
||||||
|
throw new \Exception('죄송합니다 재고가 부족합니다. 관리자에게 문의해 주시면 바로 처리해 드리겠습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pinIds = array_map(fn ($p) => (int)$p['id'], $pins);
|
||||||
|
|
||||||
|
// 1차 선점
|
||||||
|
$this->pinsRepo->updateStatusByIds($pinIds, 'HOLD', [
|
||||||
|
'order_id' => $orderId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$memNo = (int)($row->order_mem_no ?? 0);
|
||||||
|
$oid = (string)($row->order_oid ?? '');
|
||||||
|
|
||||||
|
$pinsJson = [];
|
||||||
|
foreach ($pins as $idx => $pin) {
|
||||||
|
$rawPin = $this->decryptSourcePin((string)($pin['pin_code'] ?? ''));
|
||||||
|
|
||||||
|
$memberEnc = $this->encryptForIssue(
|
||||||
|
$rawPin,
|
||||||
|
$memNo,
|
||||||
|
$oid,
|
||||||
|
$orderItemId,
|
||||||
|
$idx + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
$pinsJson[] = [
|
||||||
|
'seq' => $idx + 1,
|
||||||
|
'gc_pin_id' => (int)($pin['id'] ?? 0),
|
||||||
|
'pin_enc' => $memberEnc,
|
||||||
|
'pin_mask' => $this->maskPin($rawPin),
|
||||||
|
'pin_hash' => hash('sha256', $rawPin),
|
||||||
|
'face_value' => (int)($pin['face_value'] ?? 0),
|
||||||
|
'issued_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = [[
|
||||||
|
'at' => $now,
|
||||||
|
'type' => 'ISSUE',
|
||||||
|
'code' => 'OWN_PIN_ISSUED',
|
||||||
|
'msg' => '자사핀 발행 완료',
|
||||||
|
'count' => count($pinsJson),
|
||||||
|
]];
|
||||||
|
|
||||||
|
$this->issueRepo->insert([
|
||||||
|
'issue_no' => $this->makeIssueNo(),
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'order_item_id' => $orderItemId,
|
||||||
|
'oid' => $oid,
|
||||||
|
'mem_no' => $memNo,
|
||||||
|
|
||||||
|
'product_id' => $productId,
|
||||||
|
'sku_id' => $skuId,
|
||||||
|
'item_name' => (string)($item['item_name'] ?? ''),
|
||||||
|
'item_code' => (string)($item['item_code'] ?? ''),
|
||||||
|
'qty' => $qty,
|
||||||
|
|
||||||
|
'unit_price' => (int)($item['unit_price'] ?? 0),
|
||||||
|
'unit_pay_price' => (int)($item['unit_pay_price'] ?? 0),
|
||||||
|
'line_subtotal' => (int)($item['line_subtotal'] ?? 0),
|
||||||
|
'line_fee' => (int)($item['line_fee'] ?? 0),
|
||||||
|
'line_total' => (int)($item['line_total'] ?? 0),
|
||||||
|
|
||||||
|
'supply_type' => 'OWN_PIN',
|
||||||
|
'provider_code' => 'OWN',
|
||||||
|
'provider_id' => null,
|
||||||
|
'provider_product_code' => null,
|
||||||
|
|
||||||
|
'member_delivery_type' => 'PIN_INSTANT',
|
||||||
|
'issue_status' => 'ISSUED',
|
||||||
|
|
||||||
|
'pin_count' => count($pinsJson),
|
||||||
|
'pins_json' => json_encode($pinsJson, JSON_UNESCAPED_UNICODE),
|
||||||
|
'pin_key_ver' => 1,
|
||||||
|
|
||||||
|
'issued_at' => $now,
|
||||||
|
'opened_at' => null,
|
||||||
|
|
||||||
|
'sms_phone_enc' => null,
|
||||||
|
'sms_phone_hash' => null,
|
||||||
|
'sms_sent_at' => null,
|
||||||
|
'sms_result_code' => null,
|
||||||
|
'sms_result_msg' => null,
|
||||||
|
|
||||||
|
'payout_amount' => 0,
|
||||||
|
'payout_fee_amount' => 0,
|
||||||
|
'payout_status' => 'NONE',
|
||||||
|
'payout_done_at' => null,
|
||||||
|
|
||||||
|
'cancel_status' => 'AVAILABLE',
|
||||||
|
'cancel_locked_reason' => null,
|
||||||
|
'cancelled_at' => null,
|
||||||
|
|
||||||
|
'provider_payload_json' => null,
|
||||||
|
'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE),
|
||||||
|
'meta' => json_encode([
|
||||||
|
'source' => 'OWN_PIN',
|
||||||
|
'attempt_id' => $attemptId,
|
||||||
|
'sales_method' => 'OWN_PIN',
|
||||||
|
], JSON_UNESCAPED_UNICODE),
|
||||||
|
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 최종 판매 처리
|
||||||
|
$this->pinsRepo->updateStatusByIds($pinIds, 'SOLD', [
|
||||||
|
'sold_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API_LINK 분기만 먼저 저장
|
||||||
|
* 실제 연동 통신은 다음 단계에서 구현
|
||||||
|
*/
|
||||||
|
private function issueApiLinkPlaceholder(
|
||||||
|
object|array $row,
|
||||||
|
array $item,
|
||||||
|
int $attemptId,
|
||||||
|
int $orderId,
|
||||||
|
int $orderItemId,
|
||||||
|
int $productId,
|
||||||
|
int $skuId,
|
||||||
|
int $qty,
|
||||||
|
int $apiProviderId,
|
||||||
|
string $apiProductCode,
|
||||||
|
string $now,
|
||||||
|
): void {
|
||||||
|
$row = (object)$row;
|
||||||
|
|
||||||
|
$logs = [[
|
||||||
|
'at' => $now,
|
||||||
|
'type' => 'ISSUE',
|
||||||
|
'code' => 'API_LINK_PENDING',
|
||||||
|
'msg' => '연동발행 대상 상품 - 실제 연동 구현 전',
|
||||||
|
'api_provider_id' => $apiProviderId,
|
||||||
|
'api_product_code' => $apiProductCode,
|
||||||
|
]];
|
||||||
|
|
||||||
|
$this->issueRepo->insert([
|
||||||
|
'issue_no' => $this->makeIssueNo(),
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'order_item_id' => $orderItemId,
|
||||||
|
'oid' => (string)($row->order_oid ?? ''),
|
||||||
|
'mem_no' => (int)($row->order_mem_no ?? 0),
|
||||||
|
|
||||||
|
'product_id' => $productId,
|
||||||
|
'sku_id' => $skuId,
|
||||||
|
'item_name' => (string)($item['item_name'] ?? ''),
|
||||||
|
'item_code' => (string)($item['item_code'] ?? ''),
|
||||||
|
'qty' => $qty,
|
||||||
|
|
||||||
|
'unit_price' => (int)($item['unit_price'] ?? 0),
|
||||||
|
'unit_pay_price' => (int)($item['unit_pay_price'] ?? 0),
|
||||||
|
'line_subtotal' => (int)($item['line_subtotal'] ?? 0),
|
||||||
|
'line_fee' => (int)($item['line_fee'] ?? 0),
|
||||||
|
'line_total' => (int)($item['line_total'] ?? 0),
|
||||||
|
|
||||||
|
'supply_type' => 'API_LINK',
|
||||||
|
'provider_code' => 'API_LINK',
|
||||||
|
'provider_id' => $apiProviderId > 0 ? $apiProviderId : null,
|
||||||
|
'provider_product_code' => $apiProductCode !== '' ? $apiProductCode : null,
|
||||||
|
|
||||||
|
'member_delivery_type' => 'PIN_INSTANT',
|
||||||
|
'issue_status' => 'PROCESSING',
|
||||||
|
|
||||||
|
'pin_count' => 0,
|
||||||
|
'pins_json' => null,
|
||||||
|
'pin_key_ver' => 1,
|
||||||
|
|
||||||
|
'issued_at' => null,
|
||||||
|
'opened_at' => null,
|
||||||
|
|
||||||
|
'sms_phone_enc' => null,
|
||||||
|
'sms_phone_hash' => null,
|
||||||
|
'sms_sent_at' => null,
|
||||||
|
'sms_result_code' => null,
|
||||||
|
'sms_result_msg' => null,
|
||||||
|
|
||||||
|
'payout_amount' => 0,
|
||||||
|
'payout_fee_amount' => 0,
|
||||||
|
'payout_status' => 'NONE',
|
||||||
|
'payout_done_at' => null,
|
||||||
|
|
||||||
|
'cancel_status' => 'AVAILABLE',
|
||||||
|
'cancel_locked_reason' => null,
|
||||||
|
'cancelled_at' => null,
|
||||||
|
|
||||||
|
'provider_payload_json' => json_encode([
|
||||||
|
'api_provider_id' => $apiProviderId,
|
||||||
|
'api_product_code' => $apiProductCode,
|
||||||
|
], JSON_UNESCAPED_UNICODE),
|
||||||
|
'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE),
|
||||||
|
'meta' => json_encode([
|
||||||
|
'source' => 'API_LINK',
|
||||||
|
'attempt_id' => $attemptId,
|
||||||
|
'sales_method' => 'API_LINK',
|
||||||
|
], JSON_UNESCAPED_UNICODE),
|
||||||
|
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeJson(string $json): array
|
||||||
|
{
|
||||||
|
$json = trim($json);
|
||||||
|
if ($json === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$arr = json_decode($json, true);
|
||||||
|
return is_array($arr) ? $arr : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decryptSourcePin(string $enc): string
|
||||||
|
{
|
||||||
|
$enc = trim($enc);
|
||||||
|
if ($enc === '') {
|
||||||
|
throw new \RuntimeException('원본 핀 데이터가 비어 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Crypt::decryptString($enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maskPin(string $raw): string
|
||||||
|
{
|
||||||
|
$v = preg_replace('/\s+/', '', $raw);
|
||||||
|
$len = mb_strlen($v);
|
||||||
|
|
||||||
|
if ($len <= 4) {
|
||||||
|
return str_repeat('*', $len);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($len <= 8) {
|
||||||
|
return mb_substr($v, 0, 2)
|
||||||
|
. str_repeat('*', max(1, $len - 4))
|
||||||
|
. mb_substr($v, -2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_substr($v, 0, 4)
|
||||||
|
. str_repeat('*', max(4, $len - 6))
|
||||||
|
. mb_substr($v, -2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 발행용 암호화
|
||||||
|
* 현재는 Crypt 기반으로 suffix를 붙이는 방식
|
||||||
|
* 추후 HKDF 기반 파생키 구조로 교체 가능
|
||||||
|
*/
|
||||||
|
private function encryptForIssue(string $rawPin, int $memNo, string $oid, int $orderItemId, int $seq): string
|
||||||
|
{
|
||||||
|
return Crypt::encryptString(
|
||||||
|
$rawPin . '|M:' . $memNo . '|O:' . $oid . '|I:' . $orderItemId . '|S:' . $seq
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeIssueNo(): string
|
||||||
|
{
|
||||||
|
return 'GI' . now()->format('YmdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -107,7 +107,7 @@ return [
|
|||||||
[
|
[
|
||||||
'category' => 'code',
|
'category' => 'code',
|
||||||
'q' => '상품 코드는 어디에서 확인하나요?',
|
'q' => '상품 코드는 어디에서 확인하나요?',
|
||||||
'a' => "마이페이지 > 이용내역에서 확인 가능합니다.\n단, 일부 상품은 휴대폰으로 즉시 전송되어 이용내역에서 확인이 어려울 수 있습니다.\n미수신/확인 불가 시 1:1 문의로 접수해 주세요.",
|
'a' => "마이페이지 > 구매내역에서 확인 가능합니다.\n단, 일부 상품은 휴대폰으로 즉시 전송되어 구매내역에서 확인이 어려울 수 있습니다.\n미수신/확인 불가 시 1:1 문의로 접수해 주세요.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'category' => 'code',
|
'category' => 'code',
|
||||||
|
|||||||
@ -86,7 +86,7 @@ return [
|
|||||||
],
|
],
|
||||||
'mypage_tabs' => [
|
'mypage_tabs' => [
|
||||||
['label' => '나의정보', 'route' => 'web.mypage.info.index', 'key' => 'info'],
|
['label' => '나의정보', 'route' => 'web.mypage.info.index', 'key' => 'info'],
|
||||||
['label' => '이용내역', 'route' => 'web.mypage.usage.index', 'key' => 'usage'],
|
['label' => '구매내역', 'route' => 'web.mypage.usage.index', 'key' => 'usage'],
|
||||||
['label' => '교환내역', 'route' => 'web.mypage.exchange.index', 'key' => 'exchange'],
|
['label' => '교환내역', 'route' => 'web.mypage.exchange.index', 'key' => 'exchange'],
|
||||||
['label' => '1:1문의내역', 'route' => 'web.mypage.qna.index', 'key' => 'qna'],
|
['label' => '1:1문의내역', 'route' => 'web.mypage.qna.index', 'key' => 'qna'],
|
||||||
],
|
],
|
||||||
|
|||||||
@ -51,8 +51,8 @@
|
|||||||
['label' => '카테고리 관리', 'route' => 'admin.categories.index', 'roles' => ['super_admin','product']],
|
['label' => '카테고리 관리', 'route' => 'admin.categories.index', 'roles' => ['super_admin','product']],
|
||||||
['label' => '결제/매입/출금 수수료 관리', 'route' => 'admin.fees.index', 'roles' => ['super_admin','product']],
|
['label' => '결제/매입/출금 수수료 관리', 'route' => 'admin.fees.index', 'roles' => ['super_admin','product']],
|
||||||
['label' => 'API 연동판매 코드 관리', 'route' => 'admin.sale-codes.index', 'roles' => ['super_admin','product']],
|
['label' => 'API 연동판매 코드 관리', 'route' => 'admin.sale-codes.index', 'roles' => ['super_admin','product']],
|
||||||
['label' => '상품 이미지 라이브러리 관리', 'route' => 'admin.media.index', 'roles' => ['super_admin','product']],
|
['label' => '이미지 라이브러리 관리', 'route' => 'admin.media.index', 'roles' => ['super_admin','product']],
|
||||||
['label' => '판매 상품등록', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']],
|
['label' => '판매상품관리', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@ -53,7 +53,6 @@
|
|||||||
|
|
||||||
<form action="{{ route('admin.products.store') }}" method="POST">
|
<form action="{{ route('admin.products.store') }}" method="POST">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #3b82f6;">
|
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #3b82f6;">
|
||||||
<div class="section-title">📦 1. 상품 기본 정보</div>
|
<div class="section-title">📦 1. 상품 기본 정보</div>
|
||||||
<div style="display: flex; gap: 24px;">
|
<div style="display: flex; gap: 24px;">
|
||||||
@ -94,30 +93,99 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label">상품 유형 / 매입 허용</label>
|
<label class="a-label">상품 유형</label>
|
||||||
<div style="display:flex; gap:10px;">
|
<div style="display:flex; gap:10px;">
|
||||||
<select name="product_type" class="a-input" style="flex:1;">
|
<select name="product_type" class="a-input" style="flex:1;">
|
||||||
<option value="ONLINE" {{ old('product_type') == 'ONLINE' ? 'selected' : '' }}>온라인 발송</option>
|
<option value="ONLINE" {{ old('product_type') == 'ONLINE' ? 'selected' : '' }}>온라인 발송</option>
|
||||||
<option value="DELIVERY" {{ old('product_type') == 'DELIVERY' ? 'selected' : '' }}>실물 배송</option>
|
<option value="DELIVERY" {{ old('product_type') == 'DELIVERY' ? 'selected' : '' }}>실물 배송</option>
|
||||||
</select>
|
</select>
|
||||||
<select name="is_buyback_allowed" class="a-input" style="flex:1;">
|
</div>
|
||||||
<option value="0" {{ old('is_buyback_allowed') == '0' ? 'selected' : '' }}>매입 불가</option>
|
</div>
|
||||||
<option value="1" {{ old('is_buyback_allowed') == '1' ? 'selected' : '' }}>매입 허용</option>
|
@php
|
||||||
|
$cardPayments = [];
|
||||||
|
$otherPayments = [];
|
||||||
|
foreach ($payments as $pm) {
|
||||||
|
$code = (string)($pm->code ?? '');
|
||||||
|
if ($code !== '' && str_starts_with($code, 'CREDIT_CARD')) $cardPayments[] = $pm;
|
||||||
|
else $otherPayments[] = $pm;
|
||||||
|
}
|
||||||
|
|
||||||
|
$savedPaymentsRaw = old('payment_methods', []);
|
||||||
|
if (!is_array($savedPaymentsRaw)) $savedPaymentsRaw = [];
|
||||||
|
$savedPayments = array_map('intval', $savedPaymentsRaw);
|
||||||
|
|
||||||
|
$cardIds = array_map(fn($pm) => (int)$pm->id, $cardPayments);
|
||||||
|
|
||||||
|
$selectedCardId = old('payment_card_method');
|
||||||
|
if (($selectedCardId === null || $selectedCardId === '') && !empty($savedPayments)) {
|
||||||
|
$found = array_values(array_intersect($savedPayments, $cardIds));
|
||||||
|
$selectedCardId = $found[0] ?? '';
|
||||||
|
}
|
||||||
|
// 신규 등록(결제수단 아무것도 선택 안한 상태)일 땐 카드도 기본 1개 선택 (기존 '전체 체크' 흐름 유지)
|
||||||
|
if (($selectedCardId === null || $selectedCardId === '') && empty($savedPayments) && !empty($cardPayments)) {
|
||||||
|
$selectedCardId = (string)$cardPayments[0]->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 카드 결제는 체크박스에서 제외하기 위해 제거
|
||||||
|
$displayPayments = array_values(array_diff($savedPayments, $cardIds));
|
||||||
|
|
||||||
|
// 신규 등록 기본값: 기존처럼 "기타 결제수단"은 전체 체크
|
||||||
|
$defaultAllOthersChecked = empty($savedPayments);
|
||||||
|
@endphp
|
||||||
|
<div class="a-field" style="grid-column: span 2;">
|
||||||
|
<label class="a-label">결제 수단 허용</label>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:18px; flex-wrap:wrap; align-items:flex-start;">
|
||||||
|
<div style="min-width:260px;">
|
||||||
|
<div class="a-muted" style="font-size:12px; margin-bottom:6px;">카드 결제 (1개 선택)</div>
|
||||||
|
<select name="payment_card_method" class="a-input" style="width:260px;">
|
||||||
|
<option value="">-- 카드 결제 선택 안함 --</option>
|
||||||
|
@foreach($cardPayments as $pm)
|
||||||
|
<option value="{{ $pm->id }}" {{ (string)$selectedCardId === (string)$pm->id ? 'selected' : '' }}>
|
||||||
|
{{ $pm->name }}{{ $pm->code ? ' ('.$pm->code.')' : '' }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="a-field" style="grid-column: span 2;">
|
<div style="flex:1; min-width:260px;">
|
||||||
<label class="a-label">결제 수단 허용 (다중 선택)</label>
|
<div class="a-muted" style="font-size:12px; margin-bottom:6px;">기타 결제수단 (복수 선택)</div>
|
||||||
<div class="check-group">
|
<div class="check-group">
|
||||||
@foreach($payments as $pm)
|
@foreach($otherPayments as $pm)
|
||||||
<label class="check-item">
|
<label class="check-item">
|
||||||
<input type="checkbox" name="payment_methods[]" value="{{ $pm->id }}" class="payment-checkbox" data-name="{{ $pm->name }}"
|
<input type="checkbox" name="payment_methods[]" value="{{ $pm->id }}" class="payment-checkbox" data-name="{{ $pm->name }}"
|
||||||
{{ in_array($pm->id, old('payment_methods', [])) || empty(old('payment_methods')) ? 'checked' : '' }}>
|
{{ in_array((int)$pm->id, $displayPayments) || $defaultAllOthersChecked ? 'checked' : '' }}>
|
||||||
<span>{{ $pm->name }}</span>
|
<span>{{ $pm->name }}{{ $pm->code ? ' ('.$pm->code.')' : '' }}</span>
|
||||||
</label>
|
</label>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$savedPins = old('pin_check_methods');
|
||||||
|
if (!is_array($savedPins) || empty($savedPins)) $savedPins = ['PIN_INSTANT'];
|
||||||
|
@endphp
|
||||||
|
<div class="a-field" style="grid-column: span 2;">
|
||||||
|
<label class="a-label">핀번호 확인 방법 (복수 선택)</label>
|
||||||
|
<div class="check-group">
|
||||||
|
<label class="check-item">
|
||||||
|
<input type="checkbox" name="pin_check_methods[]" value="PIN_INSTANT" {{ in_array('PIN_INSTANT', $savedPins, true) ? 'checked' : '' }}>
|
||||||
|
<span>핀즉시확인</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-item">
|
||||||
|
<input type="checkbox" name="pin_check_methods[]" value="SMS" {{ in_array('SMS', $savedPins, true) ? 'checked' : '' }}>
|
||||||
|
<span>SMS 발송</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-item">
|
||||||
|
<input type="checkbox" name="pin_check_methods[]" value="BUYBACK" {{ in_array('BUYBACK', $savedPins, true) ? 'checked' : '' }}>
|
||||||
|
<span>재판매(매입)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="a-field" style="grid-column: span 2;">
|
<div class="a-field" style="grid-column: span 2;">
|
||||||
<label class="a-label">판매 기간 설정</label>
|
<label class="a-label">판매 기간 설정</label>
|
||||||
<div style="display:flex; gap:10px; align-items:center;">
|
<div style="display:flex; gap:10px; align-items:center;">
|
||||||
|
|||||||
@ -51,6 +51,7 @@
|
|||||||
<form action="{{ route('admin.products.update', $product->id) }}" method="POST">
|
<form action="{{ route('admin.products.update', $product->id) }}" method="POST">
|
||||||
@csrf
|
@csrf
|
||||||
@method('PUT')
|
@method('PUT')
|
||||||
|
|
||||||
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #3b82f6;">
|
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #3b82f6;">
|
||||||
<div class="section-title">📦 1. 상품 기본 정보</div>
|
<div class="section-title">📦 1. 상품 기본 정보</div>
|
||||||
<div style="display: flex; gap: 24px;">
|
<div style="display: flex; gap: 24px;">
|
||||||
@ -91,35 +92,95 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label">상품 유형 / 매입 허용</label>
|
<label class="a-label">상품 유형</label>
|
||||||
<div style="display:flex; gap:10px;">
|
<div style="display:flex; gap:10px;">
|
||||||
<select name="product_type" class="a-input" style="flex:1;">
|
<select name="product_type" class="a-input" style="flex:1;">
|
||||||
<option value="ONLINE" {{ old('product_type', $product->product_type) == 'ONLINE' ? 'selected' : '' }}>온라인 발송</option>
|
<option value="ONLINE" {{ old('product_type', $product->product_type) == 'ONLINE' ? 'selected' : '' }}>온라인 발송</option>
|
||||||
<option value="DELIVERY" {{ old('product_type', $product->product_type) == 'DELIVERY' ? 'selected' : '' }}>실물 배송</option>
|
<option value="DELIVERY" {{ old('product_type', $product->product_type) == 'DELIVERY' ? 'selected' : '' }}>실물 배송</option>
|
||||||
</select>
|
</select>
|
||||||
<select name="is_buyback_allowed" class="a-input" style="flex:1;">
|
|
||||||
<option value="0" {{ old('is_buyback_allowed', $product->is_buyback_allowed) == '0' ? 'selected' : '' }}>매입 불가</option>
|
|
||||||
<option value="1" {{ old('is_buyback_allowed', $product->is_buyback_allowed) == '1' ? 'selected' : '' }}>매입 허용</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@php
|
@php
|
||||||
// JSON 결제 수단 배열화
|
$savedPaymentsRaw = old('payment_methods', $product->allowed_payments ?? []);
|
||||||
$savedPayments = old('payment_methods', $product->allowed_payments ?? []);
|
if (!is_array($savedPaymentsRaw)) $savedPaymentsRaw = [];
|
||||||
|
$savedPayments = array_map('intval', $savedPaymentsRaw);
|
||||||
|
|
||||||
|
$cardPayments = [];
|
||||||
|
$otherPayments = [];
|
||||||
|
foreach ($payments as $pm) {
|
||||||
|
$code = (string)($pm->code ?? '');
|
||||||
|
if ($code !== '' && str_starts_with($code, 'CREDIT_CARD')) $cardPayments[] = $pm;
|
||||||
|
else $otherPayments[] = $pm;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cardIds = array_map(fn($pm) => (int)$pm->id, $cardPayments);
|
||||||
|
|
||||||
|
$selectedCardId = old('payment_card_method');
|
||||||
|
if ($selectedCardId === null || $selectedCardId === '') {
|
||||||
|
$found = array_values(array_intersect($savedPayments, $cardIds));
|
||||||
|
$selectedCardId = $found[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카드 결제는 체크박스에서 제외하기 위해 제거
|
||||||
|
$displayPayments = array_values(array_diff($savedPayments, $cardIds));
|
||||||
|
|
||||||
|
// 혹시 기존 데이터가 비어있다면(구상품), 기존 신규등록처럼 "기타 결제수단" 전체 체크
|
||||||
|
$defaultAllOthersChecked = empty($savedPayments);
|
||||||
|
|
||||||
|
$savedPins = old('pin_check_methods', $product->pin_check_methods ?? []);
|
||||||
|
if (!is_array($savedPins) || empty($savedPins)) $savedPins = ['PIN_INSTANT'];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="a-field" style="grid-column: span 2;">
|
<div class="a-field" style="grid-column: span 2;">
|
||||||
<label class="a-label">결제 수단 허용 (다중 선택)</label>
|
<label class="a-label">결제 수단 허용</label>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:18px; flex-wrap:wrap; align-items:flex-start;">
|
||||||
|
<div style="min-width:260px;">
|
||||||
|
<div class="a-muted" style="font-size:12px; margin-bottom:6px;">카드 결제 (1개 선택)</div>
|
||||||
|
<select name="payment_card_method" class="a-input" style="width:260px;">
|
||||||
|
<option value="">-- 카드 결제 선택 안함 --</option>
|
||||||
|
@foreach($cardPayments as $pm)
|
||||||
|
<option value="{{ $pm->id }}" {{ (string)$selectedCardId === (string)$pm->id ? 'selected' : '' }}>
|
||||||
|
{{ $pm->name }}{{ $pm->code ? ' ('.$pm->code.')' : '' }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1; min-width:260px;">
|
||||||
|
<div class="a-muted" style="font-size:12px; margin-bottom:6px;">기타 결제수단 (복수 선택)</div>
|
||||||
<div class="check-group">
|
<div class="check-group">
|
||||||
@foreach($payments as $pm)
|
@foreach($otherPayments as $pm)
|
||||||
<label class="check-item">
|
<label class="check-item">
|
||||||
<input type="checkbox" name="payment_methods[]" value="{{ $pm->id }}" class="payment-checkbox" data-name="{{ $pm->name }}"
|
<input type="checkbox" name="payment_methods[]" value="{{ $pm->id }}" class="payment-checkbox" data-name="{{ $pm->name }}"
|
||||||
{{ in_array($pm->id, $savedPayments) ? 'checked' : '' }}>
|
{{ in_array((int)$pm->id, $displayPayments) || $defaultAllOthersChecked ? 'checked' : '' }}>
|
||||||
<span>{{ $pm->name }}</span>
|
<span>{{ $pm->name }}{{ $pm->code ? ' ('.$pm->code.')' : '' }}</span>
|
||||||
</label>
|
</label>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-field" style="grid-column: span 2;">
|
||||||
|
<label class="a-label">핀번호 확인 방법 (복수 선택)</label>
|
||||||
|
<div class="check-group">
|
||||||
|
<label class="check-item">
|
||||||
|
<input type="checkbox" name="pin_check_methods[]" value="PIN_INSTANT" {{ in_array('PIN_INSTANT', $savedPins, true) ? 'checked' : '' }}>
|
||||||
|
<span>핀즉시확인</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-item">
|
||||||
|
<input type="checkbox" name="pin_check_methods[]" value="SMS" {{ in_array('SMS', $savedPins, true) ? 'checked' : '' }}>
|
||||||
|
<span>SMS 발송</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-item">
|
||||||
|
<input type="checkbox" name="pin_check_methods[]" value="BUYBACK" {{ in_array('BUYBACK', $savedPins, true) ? 'checked' : '' }}>
|
||||||
|
<span>재판매(이게 선택이 되면 해당 상품권은 핀발행 없이 바로 매입/출금 됩니다.)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="a-field" style="grid-column: span 2;">
|
<div class="a-field" style="grid-column: span 2;">
|
||||||
<label class="a-label">판매 기간 설정</label>
|
<label class="a-label">판매 기간 설정</label>
|
||||||
<div style="display:flex; gap:10px; align-items:center;">
|
<div style="display:flex; gap:10px; align-items:center;">
|
||||||
|
|||||||
@ -20,6 +20,20 @@
|
|||||||
|
|
||||||
.p-name { font-weight: bold; font-size: 14px; margin-bottom: 4px; display: block; color: #fff; text-decoration: none; }
|
.p-name { font-weight: bold; font-size: 14px; margin-bottom: 4px; display: block; color: #fff; text-decoration: none; }
|
||||||
.p-name:hover { text-decoration: underline; color: #60a5fa; }
|
.p-name:hover { text-decoration: underline; color: #60a5fa; }
|
||||||
|
|
||||||
|
.badges{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;}
|
||||||
|
.badge{
|
||||||
|
display:inline-flex;align-items:center;
|
||||||
|
padding:2px 8px;border-radius:999px;
|
||||||
|
font-size:11px;line-height:1.6;
|
||||||
|
border:1px solid rgba(0,0,0,.12);
|
||||||
|
background:rgba(0,0,0,.04);
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.badge--muted{opacity:.65;}
|
||||||
|
.badge--ok{background:rgba(0,160,60,.08);border-color:rgba(0,160,60,.25);}
|
||||||
|
.badge--info{background:rgba(0,120,220,.08);border-color:rgba(0,120,220,.25);}
|
||||||
|
.badge--warn{background:rgba(220,140,0,.10);border-color:rgba(220,140,0,.25);}
|
||||||
</style>
|
</style>
|
||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
@ -75,7 +89,9 @@
|
|||||||
<th style="width: 70px;">이미지</th>
|
<th style="width: 70px;">이미지</th>
|
||||||
<th style="width: 250px;">카테고리 / 상품명</th>
|
<th style="width: 250px;">카테고리 / 상품명</th>
|
||||||
<th>권종 상세 (API / 재고)</th>
|
<th>권종 상세 (API / 재고)</th>
|
||||||
<th style="text-align: center; width: 140px;">판매 기간 / 매입</th>
|
<th style="text-align: center; width: 100px;">판매기간</th>
|
||||||
|
<th style="text-align:center; width: 100px;">핀확인방법</th>
|
||||||
|
<th style="text-align:center; width: 100px;">결제수단</th>
|
||||||
<th style="width: 80px; text-align: center;">상태</th>
|
<th style="width: 80px; text-align: center;">상태</th>
|
||||||
<th style="width: 80px; text-align: center;">관리</th>
|
<th style="width: 80px; text-align: center;">관리</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -129,12 +145,68 @@
|
|||||||
@else
|
@else
|
||||||
<div style="color: #f59e0b; margin-bottom: 4px;">{{ \Carbon\Carbon::parse($p->sales_end_at)->format('Y-m-d H시 마감') }}</div>
|
<div style="color: #f59e0b; margin-bottom: 4px;">{{ \Carbon\Carbon::parse($p->sales_end_at)->format('Y-m-d H시 마감') }}</div>
|
||||||
@endif
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
@if($p->is_buyback_allowed)
|
<td style="text-align: center; font-size: 12px;">
|
||||||
<span class="pill pill--outline pill--info" style="font-size: 10px;">매입가능</span>
|
@php
|
||||||
@else
|
$pin = $p->pin_check_methods ?? null;
|
||||||
<span class="pill pill--outline pill--muted" style="font-size: 10px;">매입불가</span>
|
if (is_string($pin)) $pin = json_decode($pin, true) ?: [];
|
||||||
@endif
|
if (!is_array($pin)) $pin = [];
|
||||||
|
|
||||||
|
$pinMap = [
|
||||||
|
'PIN_INSTANT' => ['label' => '핀즉시확인', 'cls' => 'badge--ok'],
|
||||||
|
'SMS' => ['label' => 'SMS 발송', 'cls' => 'badge--info'],
|
||||||
|
'BUYBACK' => ['label' => '재판매(매입)','cls' => 'badge--warn'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$pinBadges = [];
|
||||||
|
foreach ($pin as $k) {
|
||||||
|
if (isset($pinMap[$k])) $pinBadges[] = $pinMap[$k];
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="badges">
|
||||||
|
@forelse($pinBadges as $b)
|
||||||
|
<span class="badge {{ $b['cls'] }}">{{ $b['label'] }}</span>
|
||||||
|
@empty
|
||||||
|
<span class="badge badge--muted">-</span>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center; font-size:12px;">
|
||||||
|
@php
|
||||||
|
$allowed = $p->allowed_payments ?? null;
|
||||||
|
if (is_string($allowed)) $allowed = json_decode($allowed, true) ?: [];
|
||||||
|
if (!is_array($allowed)) $allowed = [];
|
||||||
|
|
||||||
|
// $payments 가 뷰에 넘어온다는 전제 (create/edit처럼)
|
||||||
|
$payMap = [];
|
||||||
|
if (isset($payments) && is_iterable($payments)) {
|
||||||
|
foreach ($payments as $pm) {
|
||||||
|
$id = (int)($pm->id ?? 0);
|
||||||
|
if ($id <= 0) continue;
|
||||||
|
|
||||||
|
$name = trim((string)($pm->name ?? '')); // 있으면 name 우선
|
||||||
|
$code = trim((string)($pm->code ?? '')); // 없으면 code
|
||||||
|
$payMap[$id] = $name !== '' ? $name : ($code !== '' ? $code : ('#'.$id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payLabels = [];
|
||||||
|
foreach ($allowed as $pid) {
|
||||||
|
$pid = (int)$pid;
|
||||||
|
if ($pid <= 0) continue;
|
||||||
|
$payLabels[] = $payMap[$pid] ?? ('#'.$pid); // fallback: #id
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="badges">
|
||||||
|
@forelse($payLabels as $lb)
|
||||||
|
<span class="badge">{{ $lb }}</span>
|
||||||
|
@empty
|
||||||
|
<span class="badge badge--muted">-</span>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: center;">
|
<td style="text-align: center;">
|
||||||
@if($p->status === 'ACTIVE') <span class="pill pill--ok">판매중</span>
|
@if($p->status === 'ACTIVE') <span class="pill pill--ok">판매중</span>
|
||||||
@ -146,7 +218,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr><td colspan="7" style="text-align: center; padding: 40px;">등록된 상품이 없습니다.</td></tr>
|
<tr><td colspan="9" style="text-align: center; padding: 40px;">등록된 상품이 없습니다.</td></tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@ -128,10 +128,67 @@
|
|||||||
<label class="a-label">연동사 코드 (예: DANAL)</label>
|
<label class="a-label">연동사 코드 (예: DANAL)</label>
|
||||||
<input class="a-input" name="code" id="pvCode" required>
|
<input class="a-input" name="code" id="pvCode" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label">연동사 노출명 (예: 다날)</label>
|
<label class="a-label">연동사 노출명</label>
|
||||||
<input class="a-input" name="name" id="pvName" required>
|
<input class="a-input" name="name" id="pvName" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">전송 방식</label>
|
||||||
|
<select class="a-input" name="transport_type" id="pvTransportType" required>
|
||||||
|
<option value="HTTP_FORM">HTTP_FORM</option>
|
||||||
|
<option value="HTTP_ENCRYPTED">HTTP_ENCRYPTED</option>
|
||||||
|
<option value="TCP_SOCKET">TCP_SOCKET</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">Base URL (HTTP 계열만)</label>
|
||||||
|
<input class="a-input" name="base_url" id="pvBaseUrl" placeholder="https://api.example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 120px; gap:12px;">
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">Host (TCP 계열만)</label>
|
||||||
|
<input class="a-input" name="host" id="pvHost" placeholder="127.0.0.1">
|
||||||
|
</div>
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">Port</label>
|
||||||
|
<input class="a-input" type="number" name="port" id="pvPort" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">연결 타임아웃(초)</label>
|
||||||
|
<input class="a-input" type="number" name="timeout_connect_sec" id="pvTimeoutConnect" value="5" min="1" max="120" required>
|
||||||
|
</div>
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">응답 타임아웃(초)</label>
|
||||||
|
<input class="a-input" type="number" name="timeout_read_sec" id="pvTimeoutRead" value="10" min="1" max="300" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">문자셋</label>
|
||||||
|
<input class="a-input" name="charset" id="pvCharset" value="UTF-8" required>
|
||||||
|
</div>
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">기본 응답 포맷</label>
|
||||||
|
<input class="a-input" name="response_format_default" id="pvResponseFormat" placeholder="JSON / XML / TEXT">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">테스트 모드</label>
|
||||||
|
<select class="a-input" name="is_test_mode" id="pvTestMode">
|
||||||
|
<option value="0">운영</option>
|
||||||
|
<option value="1">테스트</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label">사용 여부</label>
|
<label class="a-label">사용 여부</label>
|
||||||
<select class="a-input" name="is_active" id="pvActive">
|
<select class="a-input" name="is_active" id="pvActive">
|
||||||
@ -139,6 +196,29 @@
|
|||||||
<option value="0">OFF (전체 장애/중단)</option>
|
<option value="0">OFF (전체 장애/중단)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||||
|
<label class="a-check"><input type="hidden" name="supports_issue" value="0"><input type="checkbox" name="supports_issue" id="pvSupportsIssue" value="1" checked> 발행 지원</label>
|
||||||
|
<label class="a-check"><input type="hidden" name="supports_cancel" value="0"><input type="checkbox" name="supports_cancel" id="pvSupportsCancel" value="1"> 관리자 취소 지원</label>
|
||||||
|
<label class="a-check"><input type="hidden" name="supports_resend" value="0"><input type="checkbox" name="supports_resend" id="pvSupportsResend" value="1"> 재발송 지원</label>
|
||||||
|
<label class="a-check"><input type="hidden" name="supports_cancel_check" value="0"><input type="checkbox" name="supports_cancel_check" id="pvSupportsCancelCheck" value="1"> 취소가능조회 지원</label>
|
||||||
|
<label class="a-check"><input type="hidden" name="supports_network_cancel" value="0"><input type="checkbox" name="supports_network_cancel" id="pvSupportsNetworkCancel" value="1"> 망취소 지원</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-field">
|
||||||
|
<label class="a-label">업체별 상세 설정 JSON</label>
|
||||||
|
<textarea class="a-input" name="config_json" id="pvConfigJson" rows="14" style="font-family:Consolas, monospace;"
|
||||||
|
placeholder='{
|
||||||
|
"cp_id": "",
|
||||||
|
"cp_pwd_enc": "",
|
||||||
|
"subcpid": "",
|
||||||
|
"crypto_key_enc": "",
|
||||||
|
"crypto_iv_enc": ""
|
||||||
|
}'></textarea>
|
||||||
|
<div class="a-help">업체별 인증값/경로/전문 기본값은 JSON으로 저장합니다.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:10px; margin-top:10px;">
|
<div style="display:flex; gap:10px; margin-top:10px;">
|
||||||
<button type="button" class="lbtn lbtn--ghost" style="flex:1;" onclick="resetPvForm()">신규등록 초기화</button>
|
<button type="button" class="lbtn lbtn--ghost" style="flex:1;" onclick="resetPvForm()">신규등록 초기화</button>
|
||||||
<button type="submit" class="lbtn lbtn--primary" style="flex:1;">저장</button>
|
<button type="submit" class="lbtn lbtn--primary" style="flex:1;">저장</button>
|
||||||
@ -154,7 +234,6 @@
|
|||||||
<script>
|
<script>
|
||||||
const baseUrl = '{{ route('admin.sale-codes.index') }}';
|
const baseUrl = '{{ route('admin.sale-codes.index') }}';
|
||||||
|
|
||||||
// 상품 코드 수정 세팅
|
|
||||||
function editCode(cd) {
|
function editCode(cd) {
|
||||||
document.getElementById('codeTitle').innerText = '📦 상품 코드 수정 (#'+cd.id+')';
|
document.getElementById('codeTitle').innerText = '📦 상품 코드 수정 (#'+cd.id+')';
|
||||||
document.getElementById('codeForm').action = baseUrl + '/code/' + cd.id;
|
document.getElementById('codeForm').action = baseUrl + '/code/' + cd.id;
|
||||||
@ -177,16 +256,32 @@
|
|||||||
document.getElementById('codeActive').value = '1';
|
document.getElementById('codeActive').value = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 연동사 수정 세팅
|
|
||||||
function editProvider(pv) {
|
function editProvider(pv) {
|
||||||
document.getElementById('pvTitle').innerText = '🏢 연동사 수정 (#' + pv.id + ')';
|
document.getElementById('pvTitle').innerText = '🏢 연동사 수정 (#' + pv.id + ')';
|
||||||
document.getElementById('pvForm').action = baseUrl + '/provider/' + pv.id;
|
document.getElementById('pvForm').action = baseUrl + '/provider/' + pv.id;
|
||||||
document.getElementById('pvMethod').value = 'PUT';
|
document.getElementById('pvMethod').value = 'PUT';
|
||||||
|
|
||||||
document.getElementById('pvCode').value = pv.code;
|
document.getElementById('pvCode').value = pv.code || '';
|
||||||
document.getElementById('pvCode').readOnly = true; // 코드는 수정 불가
|
document.getElementById('pvCode').readOnly = true;
|
||||||
document.getElementById('pvName').value = pv.name;
|
document.getElementById('pvName').value = pv.name || '';
|
||||||
document.getElementById('pvActive').value = pv.is_active;
|
document.getElementById('pvTransportType').value = pv.transport_type || 'HTTP_FORM';
|
||||||
|
document.getElementById('pvBaseUrl').value = pv.base_url || '';
|
||||||
|
document.getElementById('pvHost').value = pv.host || '';
|
||||||
|
document.getElementById('pvPort').value = pv.port || '';
|
||||||
|
document.getElementById('pvTimeoutConnect').value = pv.timeout_connect_sec || 5;
|
||||||
|
document.getElementById('pvTimeoutRead').value = pv.timeout_read_sec || 10;
|
||||||
|
document.getElementById('pvCharset').value = pv.charset || 'UTF-8';
|
||||||
|
document.getElementById('pvResponseFormat').value = pv.response_format_default || '';
|
||||||
|
document.getElementById('pvTestMode').value = String(pv.is_test_mode ?? 0);
|
||||||
|
document.getElementById('pvActive').value = String(pv.is_active ?? 1);
|
||||||
|
|
||||||
|
document.getElementById('pvSupportsIssue').checked = Number(pv.supports_issue ?? 1) === 1;
|
||||||
|
document.getElementById('pvSupportsCancel').checked = Number(pv.supports_cancel ?? 0) === 1;
|
||||||
|
document.getElementById('pvSupportsResend').checked = Number(pv.supports_resend ?? 0) === 1;
|
||||||
|
document.getElementById('pvSupportsCancelCheck').checked = Number(pv.supports_cancel_check ?? 0) === 1;
|
||||||
|
document.getElementById('pvSupportsNetworkCancel').checked = Number(pv.supports_network_cancel ?? 0) === 1;
|
||||||
|
|
||||||
|
document.getElementById('pvConfigJson').value = pv.config_json || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPvForm() {
|
function resetPvForm() {
|
||||||
@ -197,10 +292,25 @@
|
|||||||
document.getElementById('pvCode').value = '';
|
document.getElementById('pvCode').value = '';
|
||||||
document.getElementById('pvCode').readOnly = false;
|
document.getElementById('pvCode').readOnly = false;
|
||||||
document.getElementById('pvName').value = '';
|
document.getElementById('pvName').value = '';
|
||||||
|
document.getElementById('pvTransportType').value = 'HTTP_FORM';
|
||||||
|
document.getElementById('pvBaseUrl').value = '';
|
||||||
|
document.getElementById('pvHost').value = '';
|
||||||
|
document.getElementById('pvPort').value = '';
|
||||||
|
document.getElementById('pvTimeoutConnect').value = '5';
|
||||||
|
document.getElementById('pvTimeoutRead').value = '10';
|
||||||
|
document.getElementById('pvCharset').value = 'UTF-8';
|
||||||
|
document.getElementById('pvResponseFormat').value = '';
|
||||||
|
document.getElementById('pvTestMode').value = '0';
|
||||||
document.getElementById('pvActive').value = '1';
|
document.getElementById('pvActive').value = '1';
|
||||||
|
|
||||||
|
document.getElementById('pvSupportsIssue').checked = true;
|
||||||
|
document.getElementById('pvSupportsCancel').checked = false;
|
||||||
|
document.getElementById('pvSupportsResend').checked = false;
|
||||||
|
document.getElementById('pvSupportsCancelCheck').checked = false;
|
||||||
|
document.getElementById('pvSupportsNetworkCancel').checked = false;
|
||||||
|
document.getElementById('pvConfigJson').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 드래그 앤 드롭 대신 화살표 버튼 정렬 (상품 코드 전용)
|
|
||||||
function moveRow(btn, direction) {
|
function moveRow(btn, direction) {
|
||||||
const item = btn.closest('.cat-item');
|
const item = btn.closest('.cat-item');
|
||||||
const container = item.parentNode;
|
const container = item.parentNode;
|
||||||
|
|||||||
@ -69,7 +69,7 @@
|
|||||||
<div class="border-t profile-sep"></div>
|
<div class="border-t profile-sep"></div>
|
||||||
|
|
||||||
<a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.mypage.info.index') }}">내 정보</a>
|
<a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.mypage.info.index') }}">내 정보</a>
|
||||||
<a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.mypage.usage.index') }}">이용내역</a>
|
<a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.mypage.usage.index') }}">구매내역</a>
|
||||||
<a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.cs.qna.index') }}">1:1 문의</a>
|
<a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.cs.qna.index') }}">1:1 문의</a>
|
||||||
|
|
||||||
<div class="border-t profile-sep"></div>
|
<div class="border-t profile-sep"></div>
|
||||||
|
|||||||
@ -115,7 +115,7 @@
|
|||||||
|
|
||||||
<div class="guide-note">
|
<div class="guide-note">
|
||||||
<b>확인</b>
|
<b>확인</b>
|
||||||
결제 완료 후 안내가 진행되며, 필요한 경우 마이페이지 이용내역에서 확인할 수 있어요.
|
결제 완료 후 안내가 진행되며, 필요한 경우 마이페이지 구매내역에서 확인할 수 있어요.
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@ -19,26 +19,34 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 상태는 "결제완료/결제취소" 중심 + 화면 깨짐 방지 최소 처리
|
// 상태는 "결제완료/결제취소" 중심 + 화면 깨짐 방지 최소 처리
|
||||||
$statusLabel = function ($r) {
|
$payStatusLabel = function ($r) {
|
||||||
$aCancel = (string)($r->attempt_cancel_status ?? 'none');
|
$aCancel = (string)($r->attempt_cancel_status ?? 'none');
|
||||||
$oCancel = (string)($r->order_cancel_status ?? 'none');
|
$oCancel = (string)($r->order_cancel_status ?? 'none');
|
||||||
|
|
||||||
// 결제 후 취소 인식: status/stat_pay 유지 + cancel_status=success
|
|
||||||
if ($aCancel === 'success' || $oCancel === 'success') return '결제취소';
|
if ($aCancel === 'success' || $oCancel === 'success') return '결제취소';
|
||||||
|
|
||||||
$aStatus = (string)($r->attempt_status ?? '');
|
$aStatus = (string)($r->attempt_status ?? '');
|
||||||
$oPay = (string)($r->order_stat_pay ?? '');
|
$oPay = (string)($r->order_stat_pay ?? '');
|
||||||
|
|
||||||
if ($aStatus === 'paid' || $oPay === 'p') return '결제완료';
|
if ($aStatus === 'paid' || $oPay === 'p') return '결제완료';
|
||||||
|
|
||||||
// 아래는 운영/테스트 중 섞여도 UI가 깨지지 않게 최소 표시
|
|
||||||
if ($aStatus === 'failed' || $oPay === 'f') return '결제실패';
|
if ($aStatus === 'failed' || $oPay === 'f') return '결제실패';
|
||||||
if ($aStatus === 'issued' || $oPay === 'w') return '입금대기';
|
if ($aStatus === 'issued' || $oPay === 'w') return '입금대기';
|
||||||
|
|
||||||
return '진행중';
|
return '진행중';
|
||||||
};
|
};
|
||||||
|
|
||||||
$statusClass = function ($label) {
|
$issueStatusLabel = function ($r) use ($payStatusLabel) {
|
||||||
|
$pay = $payStatusLabel($r);
|
||||||
|
|
||||||
|
// 결제취소면 발행 상태는 의미 없음
|
||||||
|
if ($pay === '결제취소') return '-';
|
||||||
|
|
||||||
|
$issuedCount = (int)($r->issued_count ?? 0);
|
||||||
|
|
||||||
|
return $issuedCount > 0 ? '발행완료' : '발행대기';
|
||||||
|
};
|
||||||
|
|
||||||
|
$payStatusClass = function ($label) {
|
||||||
return match ($label) {
|
return match ($label) {
|
||||||
'결제취소' => 'pill--danger',
|
'결제취소' => 'pill--danger',
|
||||||
'결제완료' => 'pill--ok',
|
'결제완료' => 'pill--ok',
|
||||||
@ -48,6 +56,14 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$issueStatusClass = function ($label) {
|
||||||
|
return match ($label) {
|
||||||
|
'발행완료' => 'pill--ok',
|
||||||
|
'발행대기' => 'pill--wait',
|
||||||
|
default => 'pill--muted',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
$formatDate = function ($v) {
|
$formatDate = function ($v) {
|
||||||
$s = (string)$v;
|
$s = (string)$v;
|
||||||
if ($s === '') return '-';
|
if ($s === '') return '-';
|
||||||
@ -120,8 +136,10 @@
|
|||||||
$qty = (int)($r->total_qty ?? 0);
|
$qty = (int)($r->total_qty ?? 0);
|
||||||
$money = (int)($r->pay_money ?? 0);
|
$money = (int)($r->pay_money ?? 0);
|
||||||
$method = (string)($r->pay_method ?? '');
|
$method = (string)($r->pay_method ?? '');
|
||||||
$st = $statusLabel($r);
|
$paySt = $payStatusLabel($r);
|
||||||
$stCls = $statusClass($st);
|
$payStCls = $payStatusClass($paySt);
|
||||||
|
$issueSt = $issueStatusLabel($r);
|
||||||
|
$issueStCls = $issueStatusClass($issueSt);
|
||||||
$dt = $formatDate($r->created_at ?? '');
|
$dt = $formatDate($r->created_at ?? '');
|
||||||
$href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery));
|
$href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery));
|
||||||
@endphp
|
@endphp
|
||||||
@ -129,7 +147,10 @@
|
|||||||
<a class="mcard" href="{{ $href }}">
|
<a class="mcard" href="{{ $href }}">
|
||||||
<div class="mcard__top">
|
<div class="mcard__top">
|
||||||
<div class="mcard__no">No. {{ $no }}</div>
|
<div class="mcard__no">No. {{ $no }}</div>
|
||||||
<span class="pill {{ $stCls }}">{{ $st }}</span>
|
<div class="mcard__badges">
|
||||||
|
<span class="pill {{ $payStCls }}">{{ $paySt }}</span>
|
||||||
|
<span class="pill {{ $issueStCls }}">{{ $issueSt }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mcard__title">
|
<div class="mcard__title">
|
||||||
@ -170,6 +191,7 @@
|
|||||||
<th style="width:110px;">결제수단</th>
|
<th style="width:110px;">결제수단</th>
|
||||||
<th style="width:80px;">수량</th>
|
<th style="width:80px;">수량</th>
|
||||||
<th style="width:130px;">금액</th>
|
<th style="width:130px;">금액</th>
|
||||||
|
<th style="width:120px;">결제</th>
|
||||||
<th style="width:120px;">상태</th>
|
<th style="width:120px;">상태</th>
|
||||||
<th style="width:160px;">일시</th>
|
<th style="width:160px;">일시</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -186,8 +208,10 @@
|
|||||||
$qty = (int)($r->total_qty ?? 0);
|
$qty = (int)($r->total_qty ?? 0);
|
||||||
$money = (int)($r->pay_money ?? 0);
|
$money = (int)($r->pay_money ?? 0);
|
||||||
$method = (string)($r->pay_method ?? '');
|
$method = (string)($r->pay_method ?? '');
|
||||||
$st = $statusLabel($r);
|
$paySt = $payStatusLabel($r);
|
||||||
$stCls = $statusClass($st);
|
$payStCls = $payStatusClass($paySt);
|
||||||
|
$issueSt = $issueStatusLabel($r);
|
||||||
|
$issueStCls = $issueStatusClass($issueSt);
|
||||||
$dt = $formatDate($r->created_at ?? '');
|
$dt = $formatDate($r->created_at ?? '');
|
||||||
$href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery));
|
$href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery));
|
||||||
@endphp
|
@endphp
|
||||||
@ -197,11 +221,12 @@
|
|||||||
<td>{{ $methodLabel($method) }}</td>
|
<td>{{ $methodLabel($method) }}</td>
|
||||||
<td>{{ $qty }}</td>
|
<td>{{ $qty }}</td>
|
||||||
<td class="money">{{ number_format($money) }} 원</td>
|
<td class="money">{{ number_format($money) }} 원</td>
|
||||||
<td><span class="pill {{ $stCls }}">{{ $st }}</span></td>
|
<td><span class="pill {{ $payStCls }}">{{ $paySt }}</span></td>
|
||||||
|
<td><span class="pill {{ $issueStCls }}">{{ $issueSt }}</span></td>
|
||||||
<td>{{ $dt }}</td>
|
<td>{{ $dt }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr><td colspan="7" class="empty">구매내역이 없습니다.</td></tr>
|
<tr><td colspan="8" class="empty">구매내역이 없습니다.</td></tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -305,5 +330,6 @@
|
|||||||
.list-mobile{display:none;}
|
.list-mobile{display:none;}
|
||||||
.list-desktop{display:block;}
|
.list-desktop{display:block;}
|
||||||
}
|
}
|
||||||
|
.mcard__badges{display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end;}
|
||||||
</style>
|
</style>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -6,13 +6,14 @@
|
|||||||
$order = $order ?? [];
|
$order = $order ?? [];
|
||||||
$items = $items ?? [];
|
$items = $items ?? [];
|
||||||
$pins = $pins ?? [];
|
$pins = $pins ?? [];
|
||||||
$pinsOpened = (bool)($pinsOpened ?? false);
|
$pinsOpened = (bool)($pinsOpened ?? false); // 기존 핀 오픈 상태
|
||||||
|
$pinsRevealed = (bool)($pinsRevealed ?? false); // 이번 요청에서 실핀 표시 여부
|
||||||
|
$pinsRevealLocked = (bool)($pinsRevealLocked ?? false); // 실핀 확인 이력(취소 잠금)
|
||||||
$canCancel = (bool)($canCancel ?? false);
|
$canCancel = (bool)($canCancel ?? false);
|
||||||
|
|
||||||
$backToListQuery = request()->only(['q', 'method', 'status', 'from', 'to', 'page']);
|
$backToListQuery = request()->only(['q', 'method', 'status', 'from', 'to', 'page']);
|
||||||
$backToListQuery = array_filter($backToListQuery, fn($v) => $v !== null && $v !== '');
|
$backToListQuery = array_filter($backToListQuery, fn($v) => $v !== null && $v !== '');
|
||||||
|
|
||||||
// 결제수단 한글 매핑
|
|
||||||
$methodLabel = function ($m) {
|
$methodLabel = function ($m) {
|
||||||
$m = (string)$m;
|
$m = (string)$m;
|
||||||
return match ($m) {
|
return match ($m) {
|
||||||
@ -24,12 +25,10 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 리스트와 동일한 상태 라벨
|
|
||||||
$statusLabel = function () use ($attempt, $order) {
|
$statusLabel = function () use ($attempt, $order) {
|
||||||
$aCancel = (string)($attempt['cancel_status'] ?? 'none');
|
$aCancel = (string)($attempt['cancel_status'] ?? 'none');
|
||||||
$oCancel = (string)($order['cancel_status'] ?? 'none');
|
$oCancel = (string)($order['cancel_status'] ?? 'none');
|
||||||
|
|
||||||
// 결제 후 취소는 cancel_status=success로만 인식
|
|
||||||
if ($aCancel === 'success' || $oCancel === 'success') return '결제취소';
|
if ($aCancel === 'success' || $oCancel === 'success') return '결제취소';
|
||||||
|
|
||||||
$aStatus = (string)($attempt['status'] ?? '');
|
$aStatus = (string)($attempt['status'] ?? '');
|
||||||
@ -37,14 +36,12 @@
|
|||||||
|
|
||||||
if ($aStatus === 'paid' || $oPay === 'p') return '결제완료';
|
if ($aStatus === 'paid' || $oPay === 'p') return '결제완료';
|
||||||
if ($aStatus === 'issued' || $oPay === 'w') return '입금대기';
|
if ($aStatus === 'issued' || $oPay === 'w') return '입금대기';
|
||||||
|
|
||||||
// 화면 깨짐 방지용(원하면 숨겨도 됨)
|
|
||||||
if ($aStatus === 'failed' || $oPay === 'f') return '결제실패';
|
if ($aStatus === 'failed' || $oPay === 'f') return '결제실패';
|
||||||
return '진행중';
|
return '진행중';
|
||||||
};
|
};
|
||||||
|
|
||||||
$st = $statusLabel();
|
$st = $statusLabel();
|
||||||
$isCancelledAfterPaid = ($st === '결제취소'); // 취소 완료면 전표만 남김
|
$isCancelledAfterPaid = ($st === '결제취소');
|
||||||
|
|
||||||
$statusClass = function ($label) {
|
$statusClass = function ($label) {
|
||||||
return match ($label) {
|
return match ($label) {
|
||||||
@ -56,7 +53,6 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 전표용 값
|
|
||||||
$attemptId = (int)($attempt['id'] ?? 0);
|
$attemptId = (int)($attempt['id'] ?? 0);
|
||||||
$oid = (string)($order['oid'] ?? '');
|
$oid = (string)($order['oid'] ?? '');
|
||||||
$method = (string)($order['pay_method'] ?? ($attempt['pay_method'] ?? ''));
|
$method = (string)($order['pay_method'] ?? ($attempt['pay_method'] ?? ''));
|
||||||
@ -67,30 +63,42 @@
|
|||||||
$fee = (int)($amounts['fee'] ?? 0);
|
$fee = (int)($amounts['fee'] ?? 0);
|
||||||
$payMoney = (int)($amounts['pay_money'] ?? 0);
|
$payMoney = (int)($amounts['pay_money'] ?? 0);
|
||||||
|
|
||||||
// 상품명: 현재 전달받는 변수 기준 유지 (사용자 확인 완료)
|
|
||||||
$productName = (string)($productname ?? '');
|
$productName = (string)($productname ?? '');
|
||||||
if ($productName === '') $productName = '-';
|
if ($productName === '') $productName = '-';
|
||||||
|
|
||||||
$itemName = (string)($items[0]['name'] ?? '');
|
$itemName = (string)($items[0]['name'] ?? '');
|
||||||
if ($itemName === '') $itemName = '-';
|
if ($itemName === '') $itemName = '-';
|
||||||
|
|
||||||
// 수량 합계
|
|
||||||
$totalQty = 0;
|
$totalQty = 0;
|
||||||
foreach ($items as $it) $totalQty += (int)($it['qty'] ?? 0);
|
foreach ($items as $it) $totalQty += (int)($it['qty'] ?? 0);
|
||||||
|
|
||||||
// 일시 (분까지)
|
|
||||||
$createdAt = (string)($order['created_at'] ?? ($attempt['created_at'] ?? ''));
|
$createdAt = (string)($order['created_at'] ?? ($attempt['created_at'] ?? ''));
|
||||||
$dateStr = $createdAt ? \Carbon\Carbon::parse($createdAt)->format('Y-m-d H:i') : '-';
|
$dateStr = $createdAt ? \Carbon\Carbon::parse($createdAt)->format('Y-m-d H:i') : '-';
|
||||||
|
|
||||||
// 핀 목록 "추후 조건" 대비: 지금은 보여줌
|
|
||||||
$showPinsNow = true;
|
$showPinsNow = true;
|
||||||
|
|
||||||
// 핀발행 완료 여부 (우선 pins 존재 기준)
|
|
||||||
// 추후 서버에서 bool($pinsIssuedCompleted) 내려주면 그 값 우선 사용 권장
|
|
||||||
$isPinIssuedCompleted = (bool)($pinsIssuedCompleted ?? !empty($pins));
|
$isPinIssuedCompleted = (bool)($pinsIssuedCompleted ?? !empty($pins));
|
||||||
|
|
||||||
// 오른쪽 영역 배너 모드 조건
|
|
||||||
$useRightBannerMode = $isCancelledAfterPaid || $isPinIssuedCompleted;
|
$useRightBannerMode = $isCancelledAfterPaid || $isPinIssuedCompleted;
|
||||||
|
|
||||||
|
$issueMethods = $issueMethods ?? ['PIN_INSTANT','SMS','BUYBACK'];
|
||||||
|
if (is_string($issueMethods)) $issueMethods = json_decode($issueMethods, true) ?: [];
|
||||||
|
if (!is_array($issueMethods) || empty($issueMethods)) $issueMethods = ['PIN_INSTANT','SMS','BUYBACK'];
|
||||||
|
$issueAllowed = array_fill_keys($issueMethods, true);
|
||||||
|
|
||||||
|
$issueMissing = $issueMissing ?? [];
|
||||||
|
if (is_string($issueMissing)) $issueMissing = json_decode($issueMissing, true) ?: [];
|
||||||
|
if (!is_array($issueMissing)) $issueMissing = [];
|
||||||
|
|
||||||
|
$issueMap = [
|
||||||
|
'PIN_INSTANT' => '핀번호 바로 확인',
|
||||||
|
'SMS' => 'SMS 발송',
|
||||||
|
'BUYBACK' => '구매상품권 판매',
|
||||||
|
];
|
||||||
|
$issueMissingLabels = [];
|
||||||
|
foreach ($issueMissing as $k) {
|
||||||
|
if (isset($issueMap[$k])) $issueMissingLabels[] = $issueMap[$k];
|
||||||
|
}
|
||||||
|
|
||||||
|
$issueOpenKey = (count($issueMethods) === 1) ? ($issueMethods[0] ?? null) : null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@section('title', '구매내역 상세')
|
@section('title', '구매내역 상세')
|
||||||
@ -105,9 +113,7 @@
|
|||||||
<a class="btn btn--back" href="{{ route('web.mypage.usage.index', $backToListQuery) }}">← 목록</a>
|
<a class="btn btn--back" href="{{ route('web.mypage.usage.index', $backToListQuery) }}">← 목록</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- 상단: 전표 + 우측 영역(핀발행/배너) --}}
|
|
||||||
<div class="detail-hero-grid">
|
<div class="detail-hero-grid">
|
||||||
{{-- 좌측: 영수증형 전표 --}}
|
|
||||||
<div class="receipt-card receipt-card--paper">
|
<div class="receipt-card receipt-card--paper">
|
||||||
<div class="receipt-head">
|
<div class="receipt-head">
|
||||||
<div>
|
<div>
|
||||||
@ -163,16 +169,8 @@
|
|||||||
<span class="v">{{ number_format($payMoney) }}원</span>
|
<span class="v">{{ number_format($payMoney) }}원</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(!empty($order['cancel_last_msg']))
|
|
||||||
<div class="notice-box notice-box--err">
|
|
||||||
<b>취소 처리 결과</b><br>
|
|
||||||
{{ $order['cancel_last_msg'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- 우측: 핀발행 인터랙션 또는 안내 배너 --}}
|
|
||||||
<aside class="right-panel {{ $useRightBannerMode ? 'right-panel--banner right-panel--mobile-hide' : '' }}">
|
<aside class="right-panel {{ $useRightBannerMode ? 'right-panel--banner right-panel--mobile-hide' : '' }}">
|
||||||
@if($useRightBannerMode)
|
@if($useRightBannerMode)
|
||||||
<div class="banner-stack">
|
<div class="banner-stack">
|
||||||
@ -217,11 +215,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-banner info-banner--warn">
|
<div class="info-banner {{ $pinsRevealLocked ? 'info-banner--danger' : 'info-banner--warn' }}">
|
||||||
<div class="info-banner__title">취소 제한 안내</div>
|
<div class="info-banner__title">
|
||||||
|
{{ $pinsRevealLocked ? '핀번호 확인 완료' : '핀번호 확인 안내' }}
|
||||||
|
</div>
|
||||||
<div class="info-banner__desc">
|
<div class="info-banner__desc">
|
||||||
핀 확인/발행 이후에는 결제 취소가 제한될 수 있습니다.
|
@if($pinsRevealLocked)
|
||||||
실제 취소 가능 여부는 하단 결제 취소 영역에서 확인됩니다.
|
핀번호를 확인한 주문은 회원이 직접 취소할 수 없습니다.
|
||||||
|
취소가 꼭 필요한 경우 관리자에게 문의해 주세요.
|
||||||
|
@else
|
||||||
|
핀 오픈 후에도 기본은 마스킹 상태로 유지됩니다.
|
||||||
|
“핀번호 확인” 버튼에서 2차 비밀번호 인증 후 전체 핀번호를 확인할 수 있습니다.
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@ -230,11 +235,17 @@
|
|||||||
<div class="issue-panel">
|
<div class="issue-panel">
|
||||||
<div class="issue-panel__head">
|
<div class="issue-panel__head">
|
||||||
<h3 class="issue-panel__title">핀 발행 선택</h3>
|
<h3 class="issue-panel__title">핀 발행 선택</h3>
|
||||||
|
|
||||||
|
@if(!empty($issueMissingLabels))
|
||||||
|
<div class="muted" style="font-size:12px;margin-top:6px;">
|
||||||
|
이 상품은 {{ implode(', ', $issueMissingLabels) }} 기능을 지원하지 않습니다.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="issue-picker" id="issuePicker">
|
<div class="issue-picker" id="issuePicker">
|
||||||
{{-- 옵션 1 --}}
|
@if(isset($issueAllowed['PIN_INSTANT']))
|
||||||
<div class="issue-option" data-issue-card="view">
|
<div class="issue-option {{ $issueOpenKey==='PIN_INSTANT' ? 'is-active' : '' }}" data-issue-card="view">
|
||||||
<button type="button" class="issue-option__toggle" data-issue-toggle>
|
<button type="button" class="issue-option__toggle" data-issue-toggle>
|
||||||
<div class="issue-option__kicker">즉시 확인</div>
|
<div class="issue-option__kicker">즉시 확인</div>
|
||||||
<div class="issue-option__title">핀번호 바로 확인</div>
|
<div class="issue-option__title">핀번호 바로 확인</div>
|
||||||
@ -246,15 +257,19 @@
|
|||||||
<p class="issue-option__detail-text">
|
<p class="issue-option__detail-text">
|
||||||
핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요.
|
핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요.
|
||||||
</p>
|
</p>
|
||||||
<button id="btnIssueView" type="button" class="issue-run issue-run--dark">
|
<form method="post" action="{{ route('web.mypage.usage.issue.pin_instant', ['attemptId' => $attemptId]) }}">
|
||||||
|
@csrf
|
||||||
|
<button id="btnIssuePinInstant" type="submit" class="issue-run issue-run--dark">
|
||||||
핀번호 바로 확인 실행
|
핀번호 바로 확인 실행
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- 옵션 2 --}}
|
@if(isset($issueAllowed['SMS']))
|
||||||
<div class="issue-option" data-issue-card="sms">
|
<div class="issue-option {{ $issueOpenKey==='SMS' ? 'is-active' : '' }}" data-issue-card="sms">
|
||||||
<button type="button" class="issue-option__toggle" data-issue-toggle>
|
<button type="button" class="issue-option__toggle" data-issue-toggle>
|
||||||
<div class="issue-option__kicker">문자 발송</div>
|
<div class="issue-option__kicker">문자 발송</div>
|
||||||
<div class="issue-option__title">SMS 발송</div>
|
<div class="issue-option__title">SMS 발송</div>
|
||||||
@ -265,7 +280,6 @@
|
|||||||
<div class="issue-option__detail-inner">
|
<div class="issue-option__detail-inner">
|
||||||
<p class="issue-option__detail-text">
|
<p class="issue-option__detail-text">
|
||||||
SMS 발송 시 핀번호는 저장되지 않습니다. 문자 수신 후 즉시 확인하세요.
|
SMS 발송 시 핀번호는 저장되지 않습니다. 문자 수신 후 즉시 확인하세요.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<button id="btnIssueSms" type="button" class="issue-run issue-run--sky">
|
<button id="btnIssueSms" type="button" class="issue-run issue-run--sky">
|
||||||
SMS 발송 실행
|
SMS 발송 실행
|
||||||
@ -273,9 +287,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- 옵션 3 --}}
|
@if(isset($issueAllowed['BUYBACK']))
|
||||||
<div class="issue-option" data-issue-card="sell">
|
<div class="issue-option {{ $issueOpenKey==='BUYBACK' ? 'is-active' : '' }}" data-issue-card="sell">
|
||||||
<button type="button" class="issue-option__toggle" data-issue-toggle>
|
<button type="button" class="issue-option__toggle" data-issue-toggle>
|
||||||
<div class="issue-option__kicker">재판매</div>
|
<div class="issue-option__kicker">재판매</div>
|
||||||
<div class="issue-option__title">구매상품권 판매</div>
|
<div class="issue-option__title">구매상품권 판매</div>
|
||||||
@ -287,7 +302,6 @@
|
|||||||
<p class="issue-option__detail-text">
|
<p class="issue-option__detail-text">
|
||||||
구매하신 상품권을 판매합니다. 계좌번호가 등록되어 있어야 하며,
|
구매하신 상품권을 판매합니다. 계좌번호가 등록되어 있어야 하며,
|
||||||
매입 처리 후 회원님 계좌로 입금됩니다.
|
매입 처리 후 회원님 계좌로 입금됩니다.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<button id="btnIssueSell" type="button" class="issue-run issue-run--green">
|
<button id="btnIssueSell" type="button" class="issue-run issue-run--green">
|
||||||
구매상품권 판매 실행
|
구매상품권 판매 실행
|
||||||
@ -295,6 +309,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(empty($issueAllowed))
|
||||||
|
<div class="muted" style="padding:12px 6px;">
|
||||||
|
현재 이 상품은 핀 발행 방식이 설정되지 않았습니다. 고객센터로 문의해 주세요.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@ -302,77 +323,154 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(!$isCancelledAfterPaid)
|
@if(!$isCancelledAfterPaid)
|
||||||
|
@if($showPinsNow && $hasIssuedIssues)
|
||||||
{{-- 핀 목록 --}}
|
<div class="gift-zone">
|
||||||
@if($showPinsNow)
|
<div class="gift-zone__head">
|
||||||
<div class="usage-card">
|
<div>
|
||||||
<div class="section-head">
|
<h3 class="gift-zone__title">핀번호</h3>
|
||||||
<h3 class="card-title">핀 목록</h3>
|
<div class="gift-zone__sub">
|
||||||
<div class="sub muted">
|
기본은 마스킹 상태로 표시됩니다.
|
||||||
핀 발행이 완료되면 이 영역에서 핀 정보를 확인할 수 있습니다. (현재는 UI 확인을 위해 표시 중)
|
핀번호 확인 버튼에서 2차 비밀번호를 입력하면 이번 화면에서만 전체 핀번호를 확인할 수 있습니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($pinsRevealed)
|
||||||
|
<span class="gift-badge gift-badge--danger">핀번호 확인됨</span>
|
||||||
|
@else
|
||||||
|
<span class="gift-badge">마스킹 표시중</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
@if(empty($pins))
|
@if(empty($pins))
|
||||||
|
<div class="usage-card">
|
||||||
<p class="muted">표시할 핀이 없습니다.</p>
|
<p class="muted">표시할 핀이 없습니다.</p>
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<ul class="pins">
|
<div class="gift-list">
|
||||||
@foreach($pins as $p)
|
@foreach($pins as $idx => $p)
|
||||||
@php
|
@php
|
||||||
$id = (int)($p['id'] ?? 0);
|
$raw = (string)($p['pin'] ?? '');
|
||||||
$status = (string)($p['status'] ?? '');
|
$masked = (string)($p['pin_mask'] ?? '****');
|
||||||
$raw = (string)($p['pin'] ?? $p['pin_code'] ?? $p['pin_no'] ?? '');
|
$display = $pinsRevealed ? ($raw ?: $masked) : $masked;
|
||||||
$masked = (string)($p['pin_masked'] ?? $p['pin_mask'] ?? '');
|
$amount = number_format((int)($p['face_value'] ?? 0));
|
||||||
if ($masked === '' && $raw !== '') {
|
|
||||||
$digits = preg_replace('/\s+/', '', $raw);
|
|
||||||
$masked = (mb_strlen($digits) >= 8)
|
|
||||||
? (mb_substr($digits,0,4).str_repeat('*',4).mb_substr($digits,-2))
|
|
||||||
: '****';
|
|
||||||
}
|
|
||||||
// 오픈 전에는 마스킹 우선
|
|
||||||
$display = $pinsOpened ? ($raw ?: $masked) : ($masked ?: '****');
|
|
||||||
@endphp
|
@endphp
|
||||||
<li class="pin-row">
|
|
||||||
<span class="pill">#{{ $id }}</span>
|
|
||||||
@if($status !== '') <span class="pill pill--muted">{{ $status }}</span> @endif
|
|
||||||
<span class="mono pin-code">{{ $display }}</span>
|
|
||||||
</li>
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- 취소 버튼은 맨 아래, 작게 --}}
|
<article class="gift-card">
|
||||||
<div class="usage-card cancel-box">
|
<div class="gift-card__top">
|
||||||
<h3 class="card-title">결제 취소</h3>
|
<div class="gift-card__brand">MOBILE GIFT</div>
|
||||||
<div class="muted">
|
<div class="gift-card__chip">No. {{ $idx + 1 }}</div>
|
||||||
핀을 확인/발행한 이후에는 취소가 제한될 수 있습니다.<br>
|
|
||||||
결제 후 취소는 처리 시간이 소요될 수 있으며, 취소 결과는 본 페이지에 반영됩니다.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="gift-card__body">
|
||||||
|
<div class="gift-card__name">{{ $productName }}</div>
|
||||||
|
<div class="gift-card__amount">{{ $amount }}원</div>
|
||||||
|
|
||||||
|
<div class="gift-card__pinbox">
|
||||||
|
<div class="gift-card__pinlabel">PIN NUMBER</div>
|
||||||
|
<div
|
||||||
|
class="gift-card__pincode mono"
|
||||||
|
data-pin-value="{{ $pinsRevealed ? ($raw ?: $masked) : '' }}"
|
||||||
|
>
|
||||||
|
{{ $display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gift-card__notice">
|
||||||
|
핀번호는 본인만 확인해 주세요.<br>
|
||||||
|
핀 발행이 완료된 주문은 회원이 직접 취소할 수 없습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(!$pinsRevealed)
|
||||||
|
<div class="gift-actions">
|
||||||
|
<button type="button" class="issue-run issue-run--dark gift-open-btn" onclick="openPinRevealModal()">
|
||||||
|
핀번호 확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="gift-actions">
|
||||||
|
<button type="button" class="issue-run issue-run--dark gift-open-btn" id="btnCopyPins">
|
||||||
|
핀번호 복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if($canCancel)
|
@if($canCancel)
|
||||||
<form method="post" action="{{ route('web.mypage.usage.cancel', ['attemptId' => $attemptId]) }}" class="cancel-form">
|
<div class="usage-card cancel-box">
|
||||||
|
<h3 class="card-title">결제 취소</h3>
|
||||||
|
|
||||||
|
<div class="muted">
|
||||||
|
취소 사유를 선택한 뒤 결제를 취소할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post"
|
||||||
|
action="{{ route('web.mypage.usage.cancel', ['attemptId' => $attemptId]) }}"
|
||||||
|
class="cancel-form cancel-form--inline"
|
||||||
|
id="cancelForm">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="q" value="{{ request('q', '') }}">
|
|
||||||
<input type="hidden" name="method" value="{{ request('method', '') }}">
|
<select class="inp sel" name="reason" id="cancelReason" required>
|
||||||
<input type="hidden" name="status" value="{{ request('status', '') }}">
|
<option value="">취소 사유를 선택해 주세요</option>
|
||||||
<input type="hidden" name="from" value="{{ request('from', '') }}">
|
<option value="단순 변심">단순 변심</option>
|
||||||
<input type="hidden" name="to" value="{{ request('to', '') }}">
|
<option value="상품을 잘못 선택함">상품을 잘못 선택함</option>
|
||||||
<input type="hidden" name="page" value="{{ request('page', '') }}">
|
<option value="구매 수량을 잘못 선택함">구매 수량을 잘못 선택함</option>
|
||||||
<input class="inp" name="reason" placeholder="취소 사유(선택)">
|
<option value="결제 수단을 잘못 선택함">결제 수단을 잘못 선택함</option>
|
||||||
|
<option value="중복 결제 시도">중복 결제 시도</option>
|
||||||
|
<option value="다른 상품으로 다시 구매 예정">다른 상품으로 다시 구매 예정</option>
|
||||||
|
<option value="가격을 다시 확인 후 구매 예정">가격을 다시 확인 후 구매 예정</option>
|
||||||
|
<option value="회원 정보 확인 후 다시 진행 예정">회원 정보 확인 후 다시 진행 예정</option>
|
||||||
|
<option value="프로모션/혜택 적용 후 재구매 예정">프로모션/혜택 적용 후 재구매 예정</option>
|
||||||
|
<option value="기타">기타</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<button id="btnCancel" class="btn btn--danger btn--sm" type="submit">
|
<button id="btnCancel" class="btn btn--danger btn--sm" type="submit">
|
||||||
결제 취소
|
결제 취소
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@else
|
|
||||||
<div class="notice-box">
|
|
||||||
현재 상태에서는 결제 취소가 불가능합니다.
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@endif
|
<div class="pin-auth-modal" id="pinRevealModal" hidden>
|
||||||
|
<div class="pin-auth-modal__backdrop" onclick="closePinRevealModal()"></div>
|
||||||
|
<div class="pin-auth-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pinRevealTitle">
|
||||||
|
<div class="pin-auth-modal__head">
|
||||||
|
<h3 id="pinRevealTitle">핀번호 확인</h3>
|
||||||
|
<button type="button" class="pin-auth-modal__close" onclick="closePinRevealModal()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pin-auth-modal__body">
|
||||||
|
<p class="pin-auth-modal__desc">
|
||||||
|
전체 핀번호 확인을 위해 2차 비밀번호를 입력해 주세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="pinRevealForm" method="post" action="{{ route('web.mypage.usage.reveal', ['attemptId' => $attemptId]) }}">
|
||||||
|
@csrf
|
||||||
|
<input
|
||||||
|
class="pin-auth-modal__input"
|
||||||
|
type="password"
|
||||||
|
name="pin2"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="\d{4}"
|
||||||
|
maxlength="4"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="2차 비밀번호 4자리"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="pin-auth-modal__actions">
|
||||||
|
<button type="button" class="issue-run" onclick="closePinRevealModal()">닫기</button>
|
||||||
|
<button type="submit" class="issue-run issue-run--dark">핀번호 확인</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -384,7 +482,7 @@
|
|||||||
.btn{
|
.btn{
|
||||||
display:inline-flex;align-items:center;justify-content:center;
|
display:inline-flex;align-items:center;justify-content:center;
|
||||||
padding:10px 12px;border-radius:12px;border:1px solid rgba(0,0,0,.14);
|
padding:10px 12px;border-radius:12px;border:1px solid rgba(0,0,0,.14);
|
||||||
cursor:pointer;text-decoration:none;font-size:13px; white-space:nowrap; background:#fff;
|
cursor:pointer;text-decoration:none;font-size:13px; white-space:nowrap;
|
||||||
}
|
}
|
||||||
.btn--sm{padding:8px 10px;border-radius:10px;font-size:12px;}
|
.btn--sm{padding:8px 10px;border-radius:10px;font-size:12px;}
|
||||||
.btn--danger{border-color: rgba(220,0,0,.35); color:rgb(180,0,0); font-weight:800;}
|
.btn--danger{border-color: rgba(220,0,0,.35); color:rgb(180,0,0); font-weight:800;}
|
||||||
@ -408,7 +506,6 @@
|
|||||||
.notice-box { padding:12px; border-radius:12px; background:rgba(0,0,0,.04); margin-top:10px; }
|
.notice-box { padding:12px; border-radius:12px; background:rgba(0,0,0,.04); margin-top:10px; }
|
||||||
.notice-box--err { background:rgba(220,0,0,.06); }
|
.notice-box--err { background:rgba(220,0,0,.06); }
|
||||||
|
|
||||||
/* ===== Hero grid (전표 + 오른쪽 영역) ===== */
|
|
||||||
.detail-hero-grid{
|
.detail-hero-grid{
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
@ -416,8 +513,8 @@
|
|||||||
}
|
}
|
||||||
@media (min-width: 960px){
|
@media (min-width: 960px){
|
||||||
.detail-hero-grid{
|
.detail-hero-grid{
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); /* 5:5 */
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
align-items: stretch; /* 좌우 높이 자연스럽게 맞춤 */
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.detail-hero-grid > * {
|
.detail-hero-grid > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -438,7 +535,6 @@
|
|||||||
.right-panel--mobile-hide{display:none;}
|
.right-panel--mobile-hide{display:none;}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Receipt (카드 영수증 느낌) ===== */
|
|
||||||
.receipt-card{
|
.receipt-card{
|
||||||
border:1px solid rgba(0,0,0,.08);
|
border:1px solid rgba(0,0,0,.08);
|
||||||
border-radius:18px;
|
border-radius:18px;
|
||||||
@ -497,7 +593,6 @@
|
|||||||
font-size:15px; font-weight:900;
|
font-size:15px; font-weight:900;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Right panel: banner mode ===== */
|
|
||||||
.banner-stack{
|
.banner-stack{
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
@ -532,7 +627,6 @@
|
|||||||
border-color:rgba(255,190,0,.20);
|
border-color:rgba(255,190,0,.20);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Cancel 상태용 세로 광고 배너 ===== */
|
|
||||||
.promo-vertical-banner{
|
.promo-vertical-banner{
|
||||||
border-radius:20px;
|
border-radius:20px;
|
||||||
position:relative;
|
position:relative;
|
||||||
@ -623,7 +717,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.promo-vertical-banner__footer{
|
.promo-vertical-banner__footer{
|
||||||
margin-top:auto; /* 아래 정렬 */
|
margin-top:auto;
|
||||||
border-top:1px dashed rgba(0,0,0,.10);
|
border-top:1px dashed rgba(0,0,0,.10);
|
||||||
padding-top:10px;
|
padding-top:10px;
|
||||||
}
|
}
|
||||||
@ -639,7 +733,6 @@
|
|||||||
color:rgba(0,0,0,.55);
|
color:rgba(0,0,0,.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Right panel: issue picker (색감 + 입체감 강화) ===== */
|
|
||||||
.issue-panel{
|
.issue-panel{
|
||||||
border-radius:20px;
|
border-radius:20px;
|
||||||
padding:14px;
|
padding:14px;
|
||||||
@ -661,9 +754,6 @@
|
|||||||
.issue-panel__title{
|
.issue-panel__title{
|
||||||
margin:0; font-size:17px; font-weight:900;
|
margin:0; font-size:17px; font-weight:900;
|
||||||
}
|
}
|
||||||
.issue-panel__desc{
|
|
||||||
font-size:13px; color:rgba(0,0,0,.62); line-height:1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.issue-picker{
|
.issue-picker{
|
||||||
display:flex; flex-direction:column; gap:10px;
|
display:flex; flex-direction:column; gap:10px;
|
||||||
@ -712,7 +802,6 @@
|
|||||||
transform:translateY(-1px);
|
transform:translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 카드별 컬러 포인트 */
|
|
||||||
.issue-option[data-issue-card="view"]::before{
|
.issue-option[data-issue-card="view"]::before{
|
||||||
background:linear-gradient(180deg, rgba(70,70,70,.85), rgba(20,20,20,.85));
|
background:linear-gradient(180deg, rgba(70,70,70,.85), rgba(20,20,20,.85));
|
||||||
}
|
}
|
||||||
@ -817,68 +906,254 @@
|
|||||||
border-color:rgba(0,160,60,.18);
|
border-color:rgba(0,160,60,.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Standard card / Pins / Cancel ===== */
|
|
||||||
.usage-card{border:1px solid rgba(0,0,0,.08); border-radius:16px; padding:16px; background:#fff;}
|
.usage-card{border:1px solid rgba(0,0,0,.08); border-radius:16px; padding:16px; background:#fff;}
|
||||||
.section-head{display:flex; flex-direction:column; gap:6px; margin-bottom:10px;}
|
.section-head{display:flex; flex-direction:column; gap:6px; margin-bottom:10px;}
|
||||||
.card-title{font-size:16px; margin:0;}
|
.card-title{font-size:16px; margin:0;}
|
||||||
.sub{font-size:13px;}
|
.sub{font-size:13px;}
|
||||||
|
|
||||||
.pins{margin:0; padding-left:0; list-style:none; display:flex; flex-direction:column; gap:8px;}
|
|
||||||
.pin-row{display:flex; align-items:center; gap:8px; flex-wrap:wrap;}
|
|
||||||
.pin-code{font-weight:900; letter-spacing:0.3px;}
|
|
||||||
|
|
||||||
.cancel-box{padding:14px;}
|
.cancel-box{padding:14px;}
|
||||||
.cancel-form{margin-top:10px; display:flex; flex-direction:column; gap:8px; align-items:flex-start;}
|
.cancel-form{margin-top:10px; display:flex; flex-direction:column; gap:8px; align-items:flex-start;}
|
||||||
.inp{
|
.inp{
|
||||||
padding:10px 10px; border-radius:12px; border:1px solid rgba(0,0,0,.14);
|
padding:10px 10px; border-radius:12px; border:1px solid rgba(0,0,0,.14);
|
||||||
background:#fff; font-size:13px; width:100%; max-width:420px;
|
background:#fff; font-size:13px; width:100%; max-width:420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gift-zone{display:flex;flex-direction:column;gap:14px;}
|
||||||
|
.gift-zone__head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;}
|
||||||
|
.gift-zone__title{margin:0;font-size:18px;font-weight:900;}
|
||||||
|
.gift-zone__sub{margin-top:6px;font-size:13px;color:rgba(0,0,0,.58);line-height:1.5;}
|
||||||
|
|
||||||
|
.gift-badge{
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;
|
||||||
|
min-height:34px;padding:0 12px;border-radius:999px;
|
||||||
|
border:1px solid rgba(0,0,0,.08);background:#fff;font-size:12px;font-weight:800;
|
||||||
|
}
|
||||||
|
.gift-badge--danger{
|
||||||
|
color:#b42318;background:rgba(255,90,90,.08);border-color:rgba(180,35,24,.16);
|
||||||
|
}
|
||||||
|
.gift-badge--ok{
|
||||||
|
color:#067647;background:rgba(18,183,106,.10);border-color:rgba(6,118,71,.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px;}
|
||||||
|
.gift-card{
|
||||||
|
position:relative;overflow:hidden;
|
||||||
|
border-radius:24px;
|
||||||
|
padding:18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(255,255,255,.82), rgba(255,255,255,.08) 35%),
|
||||||
|
linear-gradient(135deg, #0f172a 0%, #1e293b 44%, #334155 100%);
|
||||||
|
color:#fff;
|
||||||
|
box-shadow:0 18px 38px rgba(15,23,42,.16);
|
||||||
|
}
|
||||||
|
.gift-card::after{
|
||||||
|
content:"";
|
||||||
|
position:absolute;right:-28px;bottom:-28px;width:120px;height:120px;border-radius:50%;
|
||||||
|
background:rgba(255,255,255,.08);
|
||||||
|
}
|
||||||
|
.gift-card__top{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:18px;}
|
||||||
|
.gift-card__brand{font-size:12px;font-weight:900;letter-spacing:.18em;opacity:.92;}
|
||||||
|
.gift-card__chip{
|
||||||
|
padding:6px 10px;border-radius:999px;
|
||||||
|
background:rgba(255,255,255,.14);font-size:11px;font-weight:800;
|
||||||
|
border:1px solid rgba(255,255,255,.18);
|
||||||
|
}
|
||||||
|
.gift-card__body{position:relative;z-index:1;display:flex;flex-direction:column;gap:12px;}
|
||||||
|
.gift-card__name{font-size:17px;font-weight:900;line-height:1.35;}
|
||||||
|
.gift-card__amount{font-size:28px;font-weight:900;letter-spacing:-.02em;}
|
||||||
|
.gift-card__pinbox{
|
||||||
|
border-radius:18px;padding:14px;
|
||||||
|
background:rgba(255,255,255,.1);
|
||||||
|
border:1px solid rgba(255,255,255,.18);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.gift-card__pinlabel{font-size:11px;letter-spacing:.14em;opacity:.8;margin-bottom:6px;}
|
||||||
|
.gift-card__pincode{font-size:18px;font-weight:900;letter-spacing:.08em;word-break:break-all;}
|
||||||
|
.gift-card__notice{font-size:12px;line-height:1.55;color:rgba(255,255,255,.82);}
|
||||||
|
.gift-actions{display:flex;justify-content:center;margin-top:6px;}
|
||||||
|
.gift-open-btn{min-width:260px;}
|
||||||
|
|
||||||
|
.pin-auth-modal[hidden]{display:none !important;}
|
||||||
|
.pin-auth-modal{position:fixed;inset:0;z-index:1000;}
|
||||||
|
.pin-auth-modal__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.52);}
|
||||||
|
.pin-auth-modal__dialog{
|
||||||
|
position:relative;
|
||||||
|
width:min(92vw, 420px);
|
||||||
|
margin:8vh auto 0;
|
||||||
|
background:#fff;border-radius:24px;
|
||||||
|
box-shadow:0 24px 60px rgba(15,23,42,.22);
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.pin-auth-modal__head{
|
||||||
|
display:flex;justify-content:space-between;align-items:center;
|
||||||
|
padding:18px 18px 0 18px;
|
||||||
|
}
|
||||||
|
.pin-auth-modal__head h3{margin:0;font-size:18px;}
|
||||||
|
.pin-auth-modal__close{
|
||||||
|
border:0;background:transparent;font-size:28px;line-height:1;cursor:pointer;color:#111827;
|
||||||
|
}
|
||||||
|
.pin-auth-modal__body{padding:16px 18px 18px;}
|
||||||
|
.pin-auth-modal__desc{margin:0 0 14px;font-size:13px;line-height:1.6;color:rgba(0,0,0,.65);}
|
||||||
|
.pin-auth-modal__input{
|
||||||
|
width:100%;height:48px;border-radius:14px;border:1px solid rgba(0,0,0,.14);
|
||||||
|
padding:0 14px;font-size:15px;background:#fff;
|
||||||
|
}
|
||||||
|
.pin-auth-modal__actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;flex-wrap:wrap;}
|
||||||
|
|
||||||
|
.cancel-form--inline{
|
||||||
|
margin-top:10px;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:row;
|
||||||
|
align-items:center;
|
||||||
|
gap:8px;
|
||||||
|
flex-wrap:nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form--inline .sel{
|
||||||
|
flex:1 1 auto;
|
||||||
|
min-width:0;
|
||||||
|
max-width:none;
|
||||||
|
height:40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form--inline .btn{
|
||||||
|
flex:0 0 auto;
|
||||||
|
height:40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px){
|
||||||
|
.cancel-form--inline{
|
||||||
|
flex-direction:column;
|
||||||
|
align-items:stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form--inline .btn{
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ---- 핀 오픈(오픈 후 취소 제한 안내) ----
|
async function copyAllPins() {
|
||||||
|
const nodes = Array.from(document.querySelectorAll('[data-pin-value]'));
|
||||||
|
const pins = nodes
|
||||||
|
.map(el => (el.getAttribute('data-pin-value') || '').trim())
|
||||||
|
.filter(v => v !== '');
|
||||||
|
|
||||||
|
if (!pins.length) {
|
||||||
|
await showMsg(
|
||||||
|
"복사할 핀번호가 없습니다.",
|
||||||
|
{ type: 'alert', title: '안내' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = pins.join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
|
await showMsg(
|
||||||
|
"전체 핀번호가 복사되었습니다.",
|
||||||
|
{ type: 'alert', title: '복사 완료' }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// clipboard API 실패 시 fallback
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.left = '-9999px';
|
||||||
|
ta.style.top = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = document.execCommand('copy');
|
||||||
|
} catch (_) {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
await showMsg(
|
||||||
|
"전체 핀번호가 복사되었습니다.",
|
||||||
|
{ type: 'alert', title: '복사 완료' }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await showMsg(
|
||||||
|
"복사에 실패했습니다. 브라우저 권한을 확인해 주세요.",
|
||||||
|
{ type: 'alert', title: '복사 실패' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onOpenPinsOnce(e) {
|
async function onOpenPinsOnce(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
const form = btn ? btn.closest('form') : null;
|
||||||
|
|
||||||
const ok = await showMsg(
|
const ok = await showMsg(
|
||||||
"핀 확인(오픈) 후에는 취소가 불가능할 수 있습니다.\n\n진행할까요?",
|
"핀 확인(오픈) 후에도 핀번호는 기본 마스킹 상태로 유지됩니다.\n\n진행할까요?",
|
||||||
{ type: 'confirm', title: '핀 확인' }
|
{ type: 'confirm', title: '핀 오픈' }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
const form = e.currentTarget.closest('form');
|
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
// requestSubmit이 있으면 native validation도 같이 탄다
|
|
||||||
if (form.requestSubmit) form.requestSubmit();
|
if (form.requestSubmit) form.requestSubmit();
|
||||||
else form.submit();
|
else form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 결제 취소(confirm) ----
|
|
||||||
async function onCancelOnce(e) {
|
async function onCancelOnce(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
const form = btn ? btn.closest('form') : null;
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const reasonEl = form.querySelector('select[name="reason"]');
|
||||||
|
const reason = (reasonEl?.value || '').trim();
|
||||||
|
|
||||||
|
if (!reason) {
|
||||||
|
await showMsg(
|
||||||
|
"취소 사유를 먼저 선택해 주세요.",
|
||||||
|
{ type: 'alert', title: '취소 사유 선택' }
|
||||||
|
);
|
||||||
|
reasonEl?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ok = await showMsg(
|
const ok = await showMsg(
|
||||||
"핀 확인 전에만 취소할 수 있습니다.\n\n결제를 취소할까요?",
|
"선택한 사유로 결제를 취소할까요?\n\n취소 사유: " + reason,
|
||||||
{ type: 'confirm', title: '결제 취소' }
|
{ type: 'confirm', title: '결제 취소' }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
const form = e.currentTarget.closest('form');
|
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
if (form.requestSubmit) form.requestSubmit();
|
if (form.requestSubmit) form.requestSubmit();
|
||||||
else form.submit();
|
else form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 준비중(3버튼 alert) ----
|
async function onIssuePinInstant(e) {
|
||||||
async function onIssueViewSoon() {
|
e.preventDefault();
|
||||||
await showMsg(
|
|
||||||
"준비중입니다.\n\n핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요.",
|
const btn = e.currentTarget;
|
||||||
{ type: 'alert', title: '안내' }
|
const form = btn ? btn.closest('form') : null;
|
||||||
|
|
||||||
|
const ok = await showMsg(
|
||||||
|
"핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요.",
|
||||||
|
{ type: 'confirm', title: '핀발행' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!ok || !form) return;
|
||||||
|
|
||||||
|
if (form.requestSubmit) form.requestSubmit();
|
||||||
|
else form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onIssueSmsSoon() {
|
async function onIssueSmsSoon() {
|
||||||
@ -895,17 +1170,37 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 바인딩 (최소 1회) ----
|
function openPinRevealModal() {
|
||||||
|
const modal = document.getElementById('pinRevealModal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.hidden = false;
|
||||||
|
|
||||||
|
const input = modal.querySelector('input[name="pin2"]');
|
||||||
|
setTimeout(() => input && input.focus(), 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePinRevealModal() {
|
||||||
|
const modal = document.getElementById('pinRevealModal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.hidden = true;
|
||||||
|
|
||||||
|
const input = modal.querySelector('input[name="pin2"]');
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') closePinRevealModal();
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// 기존 로직 유지 (현재 버튼이 없을 수 있어도 그대로)
|
|
||||||
const btnOpen = document.getElementById('btnOpenPins');
|
const btnOpen = document.getElementById('btnOpenPins');
|
||||||
if (btnOpen) btnOpen.addEventListener('click', onOpenPinsOnce);
|
if (btnOpen) btnOpen.addEventListener('click', onOpenPinsOnce);
|
||||||
|
|
||||||
const btnCancel = document.getElementById('btnCancel');
|
const btnCancel = document.getElementById('btnCancel');
|
||||||
if (btnCancel) btnCancel.addEventListener('click', onCancelOnce);
|
if (btnCancel) btnCancel.addEventListener('click', onCancelOnce);
|
||||||
|
|
||||||
const btn1 = document.getElementById('btnIssueView');
|
const btnPinInstant = document.getElementById('btnIssuePinInstant');
|
||||||
if (btn1) btn1.addEventListener('click', onIssueViewSoon);
|
if (btnPinInstant) btnPinInstant.addEventListener('click', onIssuePinInstant);
|
||||||
|
|
||||||
const btn2 = document.getElementById('btnIssueSms');
|
const btn2 = document.getElementById('btnIssueSms');
|
||||||
if (btn2) btn2.addEventListener('click', onIssueSmsSoon);
|
if (btn2) btn2.addEventListener('click', onIssueSmsSoon);
|
||||||
@ -913,7 +1208,11 @@
|
|||||||
const btn3 = document.getElementById('btnIssueSell');
|
const btn3 = document.getElementById('btnIssueSell');
|
||||||
if (btn3) btn3.addEventListener('click', onIssueSellSoon);
|
if (btn3) btn3.addEventListener('click', onIssueSellSoon);
|
||||||
|
|
||||||
// ---- 핀 발행 선택형 배너 (한 번에 1개만 확장) ----
|
const btnCopyPins = document.getElementById('btnCopyPins');
|
||||||
|
if (btnCopyPins) {
|
||||||
|
btnCopyPins.addEventListener('click', copyAllPins);
|
||||||
|
}
|
||||||
|
|
||||||
const issueCards = Array.from(document.querySelectorAll('[data-issue-card]'));
|
const issueCards = Array.from(document.querySelectorAll('[data-issue-card]'));
|
||||||
issueCards.forEach((card) => {
|
issueCards.forEach((card) => {
|
||||||
const toggle = card.querySelector('[data-issue-toggle]');
|
const toggle = card.querySelector('[data-issue-toggle]');
|
||||||
@ -921,11 +1220,7 @@
|
|||||||
|
|
||||||
toggle.addEventListener('click', () => {
|
toggle.addEventListener('click', () => {
|
||||||
const isActive = card.classList.contains('is-active');
|
const isActive = card.classList.contains('is-active');
|
||||||
|
|
||||||
// 모두 닫기
|
|
||||||
issueCards.forEach(c => c.classList.remove('is-active'));
|
issueCards.forEach(c => c.classList.remove('is-active'));
|
||||||
|
|
||||||
// 방금 클릭한 카드가 닫힌 상태였으면 열기
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
card.classList.add('is-active');
|
card.classList.add('is-active');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,7 +68,7 @@
|
|||||||
<div class="m-usercard2__actions">
|
<div class="m-usercard2__actions">
|
||||||
@if($isLogin)
|
@if($isLogin)
|
||||||
<a class="m-btn2" href="{{ route('web.mypage.info.index') }}">내 정보</a>
|
<a class="m-btn2" href="{{ route('web.mypage.info.index') }}">내 정보</a>
|
||||||
<a class="m-btn2 m-btn2--ghost" href="{{ route('web.mypage.usage.index') }}">이용내역</a>
|
<a class="m-btn2 m-btn2--ghost" href="{{ route('web.mypage.usage.index') }}">구매내역</a>
|
||||||
|
|
||||||
<form action="{{ route('web.auth.logout') }}" method="post" class="m-usercard2__logout">
|
<form action="{{ route('web.auth.logout') }}" method="post" class="m-usercard2__logout">
|
||||||
@csrf
|
@csrf
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="cs-quick__card" href="{{ route('web.mypage.usage.index') }}">
|
<a class="cs-quick__card" href="{{ route('web.mypage.usage.index') }}">
|
||||||
<div class="cs-quick__title">이용내역</div>
|
<div class="cs-quick__title">구매내역</div>
|
||||||
<div class="cs-quick__desc">구매/결제/발송 진행 확인</div>
|
<div class="cs-quick__desc">구매/결제/발송 진행 확인</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@ -66,8 +66,10 @@ Route::prefix('mypage')->name('web.mypage.')
|
|||||||
|
|
||||||
Route::get('usage', [UsageController::class, 'index'])->name('usage.index');
|
Route::get('usage', [UsageController::class, 'index'])->name('usage.index');
|
||||||
Route::get('usage/{attemptId}', [UsageController::class, 'show'])->whereNumber('attemptId')->name('usage.show');
|
Route::get('usage/{attemptId}', [UsageController::class, 'show'])->whereNumber('attemptId')->name('usage.show');
|
||||||
|
Route::post('usage/{attemptId}/issue/pin-instant', [UsageController::class, 'issuePinInstant'])->whereNumber('attemptId')->name('usage.issue.pin_instant');
|
||||||
Route::post('usage/{attemptId}/open', [UsageController::class, 'openPins'])->whereNumber('attemptId')->name('usage.open');
|
Route::post('usage/{attemptId}/open', [UsageController::class, 'openPins'])->whereNumber('attemptId')->name('usage.open');
|
||||||
Route::post('usage/{attemptId}/cancel', [UsageController::class, 'cancel'])->whereNumber('attemptId')->name('usage.cancel');
|
Route::post('usage/{attemptId}/cancel', [UsageController::class, 'cancel'])->whereNumber('attemptId')->name('usage.cancel');
|
||||||
|
Route::post('usage/{attemptId}/reveal', [UsageController::class, 'revealPins'])->whereNumber('attemptId')->name('usage.reveal');
|
||||||
|
|
||||||
Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.index');
|
Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.index');
|
||||||
Route::get('qna', [MypageQnaController::class, 'index'])->name('qna.index');
|
Route::get('qna', [MypageQnaController::class, 'index'])->name('qna.index');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user