From b0545ab5b90440caa69fd3dbd93ac7c3f4e89efb Mon Sep 17 00:00:00 2001 From: sungro815 Date: Fri, 20 Feb 2026 18:11:03 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=83=81?= =?UTF-8?q?=ED=92=88=EA=B4=80=EB=A6=AC=20=EC=99=84=EB=A3=8C=20=EC=9B=B9?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EC=83=81=ED=92=88=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=83=81=EC=84=B8=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/Product/AdminCategoryController.php | 118 ++++ .../Admin/Product/AdminFeeController.php | 97 +++ .../Admin/Product/AdminMediaController.php | 89 +++ .../Admin/Product/AdminPinController.php | 92 +++ .../Admin/Product/AdminProductController.php | 109 ++++ .../Admin/Product/AdminSaleCodeController.php | 95 +++ .../Web/Product/ProductController.php | 42 ++ .../Admin/Product/AdminCategoryRepository.php | 69 ++ .../Admin/Product/AdminFeeRepository.php | 65 ++ .../Admin/Product/AdminMediaRepository.php | 64 ++ .../Admin/Product/AdminPinRepository.php | 55 ++ .../Admin/Product/AdminProductRepository.php | 148 +++++ .../Admin/Product/AdminSaleCodeRepository.php | 91 +++ .../Product/ProductRepository.php | 111 ++++ .../Admin/Product/AdminCategoryService.php | 156 +++++ .../Admin/Product/AdminFeeService.php | 127 ++++ .../Admin/Product/AdminMediaService.php | 189 ++++++ .../Admin/Product/AdminPinService.php | 169 +++++ .../Admin/Product/AdminProductService.php | 226 +++++++ .../Admin/Product/AdminSaleCodeService.php | 169 +++++ app/Services/Product/ProductService.php | 75 +++ composer.json | 1 + composer.lock | 146 ++++- config/web.php | 4 +- docs/product_table/pfy_phase1_schema.md | 52 +- .../log/MemberAccountLogController.blade.php | 2 + .../log/MemberLoginLogController.blade.php | 2 + .../MemberPasswdModifyLogController.blade.php | 2 + .../views/admin/partials/sidebar.blade.php | 30 +- .../admin/product/category/index.blade.php | 239 +++++++ .../views/admin/product/fee/index.blade.php | 187 ++++++ .../views/admin/product/media/index.blade.php | 191 ++++++ .../views/admin/product/pins/index.blade.php | 203 ++++++ .../admin/product/products/create.blade.php | 560 ++++++++++++++++ .../admin/product/products/edit.blade.php | 605 ++++++++++++++++++ .../admin/product/products/index.blade.php | 158 +++++ .../admin/product/salecode/index.blade.php | 230 +++++++ resources/views/admin/skus/create.blade.php | 168 +++++ resources/views/admin/skus/edit.blade.php | 237 +++++++ resources/views/admin/skus/index.blade.php | 268 ++++++++ resources/views/web/company/header.blade.php | 6 +- resources/views/web/layouts/subpage.blade.php | 97 +-- .../views/web/main/quick-categories.blade.php | 30 +- .../web/partials/subpage-sidenav.blade.php | 200 +++++- .../views/web/product/detail/index.blade.php | 250 ++++++++ .../views/web/product/list/index.blade.php | 58 ++ routes/admin.php | 75 +++ routes/web.php | 6 + 48 files changed, 6188 insertions(+), 175 deletions(-) create mode 100644 app/Http/Controllers/Admin/Product/AdminCategoryController.php create mode 100644 app/Http/Controllers/Admin/Product/AdminFeeController.php create mode 100644 app/Http/Controllers/Admin/Product/AdminMediaController.php create mode 100644 app/Http/Controllers/Admin/Product/AdminPinController.php create mode 100644 app/Http/Controllers/Admin/Product/AdminProductController.php create mode 100644 app/Http/Controllers/Admin/Product/AdminSaleCodeController.php create mode 100644 app/Http/Controllers/Web/Product/ProductController.php create mode 100644 app/Repositories/Admin/Product/AdminCategoryRepository.php create mode 100644 app/Repositories/Admin/Product/AdminFeeRepository.php create mode 100644 app/Repositories/Admin/Product/AdminMediaRepository.php create mode 100644 app/Repositories/Admin/Product/AdminPinRepository.php create mode 100644 app/Repositories/Admin/Product/AdminProductRepository.php create mode 100644 app/Repositories/Admin/Product/AdminSaleCodeRepository.php create mode 100644 app/Repositories/Product/ProductRepository.php create mode 100644 app/Services/Admin/Product/AdminCategoryService.php create mode 100644 app/Services/Admin/Product/AdminFeeService.php create mode 100644 app/Services/Admin/Product/AdminMediaService.php create mode 100644 app/Services/Admin/Product/AdminPinService.php create mode 100644 app/Services/Admin/Product/AdminProductService.php create mode 100644 app/Services/Admin/Product/AdminSaleCodeService.php create mode 100644 app/Services/Product/ProductService.php create mode 100644 resources/views/admin/product/category/index.blade.php create mode 100644 resources/views/admin/product/fee/index.blade.php create mode 100644 resources/views/admin/product/media/index.blade.php create mode 100644 resources/views/admin/product/pins/index.blade.php create mode 100644 resources/views/admin/product/products/create.blade.php create mode 100644 resources/views/admin/product/products/edit.blade.php create mode 100644 resources/views/admin/product/products/index.blade.php create mode 100644 resources/views/admin/product/salecode/index.blade.php create mode 100644 resources/views/admin/skus/create.blade.php create mode 100644 resources/views/admin/skus/edit.blade.php create mode 100644 resources/views/admin/skus/index.blade.php create mode 100644 resources/views/web/product/detail/index.blade.php create mode 100644 resources/views/web/product/list/index.blade.php diff --git a/app/Http/Controllers/Admin/Product/AdminCategoryController.php b/app/Http/Controllers/Admin/Product/AdminCategoryController.php new file mode 100644 index 0000000..c3319b2 --- /dev/null +++ b/app/Http/Controllers/Admin/Product/AdminCategoryController.php @@ -0,0 +1,118 @@ +service->getCategoryTree(); + return view('admin.product.category.index', ['tree' => $tree]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'parent_id' => ['nullable', 'integer'], + 'name' => ['required', 'string', 'max:50'], + 'is_active' => ['required', 'in:0,1'], + 'slug' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9\-]+$/'], + 'search_keywords' => ['nullable', 'string'], // 추가 + ]); + + $res = $this->service->storeCategory( + input: $data, + actorAdminId: (int) auth('admin')->id(), + ip: (string) $request->ip(), + ua: (string) ($request->userAgent() ?? ''), + ); + + if (!($res['ok'] ?? false)) { + return redirect()->back()->withInput()->with('toast', [ + 'type' => 'danger', + 'title' => '등록 실패', + 'message' => $res['message'], + ]); + } + + return redirect()->route('admin.categories.index')->with('toast', [ + 'type' => 'success', + 'title' => '등록 완료', + 'message' => $res['message'], + ]); + } + + public function update(int $id, Request $request) + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:50'], + 'is_active' => ['required', 'in:0,1'], + 'slug' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9\-]+$/'], + 'search_keywords' => ['nullable', 'string'], // 추가 + ]); + + $res = $this->service->updateCategory( + id: $id, + input: $data, + actorAdminId: (int) auth('admin')->id(), + ip: (string) $request->ip(), + ua: (string) ($request->userAgent() ?? ''), + ); + + if (!($res['ok'] ?? false)) { + return redirect()->back()->with('toast', [ + 'type' => 'danger', + 'title' => '수정 실패', + 'message' => $res['message'], + ]); + } + + return redirect()->route('admin.categories.index')->with('toast', [ + 'type' => 'success', + 'title' => '수정 완료', + 'message' => $res['message'], + ]); + } + + public function destroy(int $id, Request $request) + { + $res = $this->service->deleteCategory( + id: $id, + actorAdminId: (int) auth('admin')->id(), + ip: (string) $request->ip(), + ua: (string) ($request->userAgent() ?? ''), + ); + + if (!($res['ok'] ?? false)) { + return redirect()->back()->with('toast', [ + 'type' => 'danger', + 'title' => '삭제 실패', + 'message' => $res['message'], + ]); + } + + return redirect()->route('admin.categories.index')->with('toast', [ + 'type' => 'success', + 'title' => '삭제 완료', + 'message' => $res['message'], + ]); + } + + public function updateSort(Request $request) + { + $data = $request->validate([ + 'ids' => ['required', 'array'], + 'ids.*' => ['integer'], + ]); + + $res = $this->service->updateSort($data['ids'], (int) auth('admin')->id()); + return response()->json($res); + } +} diff --git a/app/Http/Controllers/Admin/Product/AdminFeeController.php b/app/Http/Controllers/Admin/Product/AdminFeeController.php new file mode 100644 index 0000000..107c57b --- /dev/null +++ b/app/Http/Controllers/Admin/Product/AdminFeeController.php @@ -0,0 +1,97 @@ +service->getFeeData(); + return view('admin.product.fee.index', $data); + } + + public function updateBuybackPolicy(Request $request) + { + $data = $request->validate([ + 'customer_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'], + 'bank_fee_type' => ['required', 'in:FLAT,PERCENT'], + 'bank_fee_value' => ['required', 'numeric', 'min:0'], + ]); + + $res = $this->service->updateBuybackPolicy($data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + + return redirect()->back()->with('toast', [ + 'type' => $res['ok'] ? 'success' : 'danger', + 'title' => '정책 수정', + 'message' => $res['message'], + ]); + } + + public function storePayment(Request $request) + { + $data = $request->validate([ + 'code' => ['required', 'string', 'max:30'], + 'name' => ['required', 'string', 'max:50'], + 'display_name' => ['required', 'string', 'max:100'], + 'customer_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'], + 'pg_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'], + 'is_active' => ['required', 'in:0,1'], + ]); + + $res = $this->service->storePaymentMethod($data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + + return redirect()->route('admin.fees.index')->with('toast', [ + 'type' => $res['ok'] ? 'success' : 'danger', + 'title' => '결제 수단 등록', + 'message' => $res['message'], + ]); + } + + public function updatePayment(int $id, Request $request) + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:50'], + 'display_name' => ['required', 'string', 'max:100'], + 'customer_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'], + 'pg_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'], + 'is_active' => ['required', 'in:0,1'], + ]); + + $res = $this->service->updatePaymentMethod($id, $data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + + return redirect()->route('admin.fees.index')->with('toast', [ + 'type' => $res['ok'] ? 'success' : 'danger', + 'title' => '결제 수단 수정', + 'message' => $res['message'], + ]); + } + + public function updatePaymentSort(Request $request) + { + $data = $request->validate([ + 'ids' => ['required', 'array'], + 'ids.*' => ['integer'], + ]); + + $res = $this->service->updatePaymentSort($data['ids'], (int) auth('admin')->id()); + return response()->json($res); + } + + public function destroyPayment(int $id, Request $request) + { + $res = $this->service->deletePaymentMethod($id, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + + return redirect()->route('admin.fees.index')->with('toast', [ + 'type' => $res['ok'] ? 'success' : 'danger', + 'title' => '결제 수단 삭제', + 'message' => $res['message'], + ]); + } +} diff --git a/app/Http/Controllers/Admin/Product/AdminMediaController.php b/app/Http/Controllers/Admin/Product/AdminMediaController.php new file mode 100644 index 0000000..cc34bb6 --- /dev/null +++ b/app/Http/Controllers/Admin/Product/AdminMediaController.php @@ -0,0 +1,89 @@ +service->list($request->all()); + return view('admin.product.media.index', $data); + } + + public function store(Request $request) + { + $request->validate([ + 'folder_name' => ['required', 'string', 'max:50'], + 'media_name' => ['nullable', 'string', 'max:100'], + 'images' => ['required', 'array', 'max:20'], + 'images.*' => ['image', 'mimes:jpeg,png,jpg,gif,webp', 'max:2048'], + ]); + + $actorId = (int) auth('admin')->id(); + $ip = (string) $request->ip(); + $ua = (string) ($request->userAgent() ?? ''); + $customName = $request->input('media_name'); + + $successCount = 0; + + // [수정된 부분] foreach의 $index는 가끔 string으로 인식될 수 있습니다. + foreach ($request->file('images') as $index => $file) { + $res = $this->service->uploadImage( + $request->only('folder_name'), + $file, + $customName, + (int) $index, // ✅ [핵심 수정] (int)를 붙여서 정수로 강제 변환 + $actorId, + $ip, + $ua + ); + + if ($res['ok']) { + $successCount++; + } + } + + return redirect()->back()->with('toast', [ + 'type' => 'success', + 'title' => '업로드 완료', + 'message' => "총 {$successCount}장의 이미지가 업로드 되었습니다.", + ]); + } + + public function destroy(int $id, Request $request) + { + $res = $this->service->deleteImage($id, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + + return redirect()->back()->with('toast', [ + 'type' => $res['ok'] ? 'success' : 'danger', + 'title' => '알림', + 'message' => $res['message'], + ]); + } + + /** + * 추후 '상품 등록' 모달 창에서 AJAX로 이미지를 불러오기 위한 API 엔드포인트 + */ + public function apiList(Request $request) + { + $data = $this->service->list($request->all()); + return response()->json([ + 'data' => $data['page']->items(), + 'links' => (string) $data['page']->links(), + ]); + } + + public function updateName(int $id, Request $request) + { + $data = $request->validate(['name' => ['required', 'string', 'max:150']]); + $res = $this->service->updateMediaName($id, $data['name'], (int)auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + return response()->json($res); + } +} diff --git a/app/Http/Controllers/Admin/Product/AdminPinController.php b/app/Http/Controllers/Admin/Product/AdminPinController.php new file mode 100644 index 0000000..ea32fae --- /dev/null +++ b/app/Http/Controllers/Admin/Product/AdminPinController.php @@ -0,0 +1,92 @@ +find($productId); + $sku = DB::table('gc_product_skus')->find($skuId); + + if (!$product || !$sku) { + return redirect()->back()->with('toast', ['type' => 'danger', 'message' => '잘못된 접근입니다.']); + } + + // ✅ 수정된 Service 반환값(배열) 받기 + $pinData = $this->service->getPinsBySku($skuId, $request->all()); + $pins = $pinData['pins']; + $stats = $pinData['stats']; // 통계 데이터 + + $pins->getCollection()->transform(function ($pin) { + try { + $decrypted = Crypt::decryptString($pin->pin_code); + $length = strlen($decrypted); + $pin->decrypted_code = substr($decrypted, 0, 4) . str_repeat('*', max(0, $length - 8)) . substr($decrypted, -4); + } catch (\Exception $e) { + $pin->decrypted_code = '복호화 에러'; + } + return $pin; + }); + + // 뷰로 변수 묶어서 전달 + return view('admin.product.pins.index', compact('product', 'sku', 'pins', 'stats')); + } + + // 핀 회수 및 다운로드 요청 처리 + public function recallBulk(Request $request, int $productId, int $skuId) + { + $request->validate([ + 'amount_type' => ['required', 'string'], + 'custom_amount' => ['nullable', 'integer', 'min:1'], + 'zip_password' => ['required', 'string', 'min:4'], + ]); + + $amount = (int)$request->input('custom_amount', 0); + if ($request->input('amount_type') !== 'ALL' && $amount <= 0) { + $amount = (int)$request->input('amount_type'); + } + + $res = $this->service->recallPins($skuId, $request->input('amount_type'), $amount, $request->input('zip_password')); + + if (!$res['ok']) { + return redirect()->back()->with('toast', ['type' => 'danger', 'title' => '오류', 'message' => $res['message']]); + } + + // 성공 시 응답으로 파일을 다운로드하고, 전송이 끝나면 서버에서 임시 zip 파일을 즉시 삭제(deleteFileAfterSend) + return response()->download($res['file_path'], $res['file_name'])->deleteFileAfterSend(true); + } + + // 대량 등록 처리 + public function storeBulk(Request $request, int $productId, int $skuId) + { + $request->validate([ + 'bulk_text' => ['required', 'string'], + 'expiry_date' => ['nullable', 'date'], + ]); + + $res = $this->service->bulkRegisterPins( + $productId, + $skuId, + $request->input('bulk_text'), + $request->input('expiry_date') + ); + + if (!$res['ok']) { + return redirect()->back()->withInput()->with('toast', ['type' => 'danger', 'title' => '등록 실패', 'message' => $res['message']]); + } + + return redirect()->route('admin.pins.index', ['productId' => $productId, 'skuId' => $skuId]) + ->with('toast', ['type' => 'success', 'title' => '완료', 'message' => $res['message']]); + } +} diff --git a/app/Http/Controllers/Admin/Product/AdminProductController.php b/app/Http/Controllers/Admin/Product/AdminProductController.php new file mode 100644 index 0000000..c4d2caf --- /dev/null +++ b/app/Http/Controllers/Admin/Product/AdminProductController.php @@ -0,0 +1,109 @@ + request()->fullUrl()]); + + $data = $this->service->list($request->all()); + return view('admin.product.products.index', $data); + } + + public function create() + { + $data = $this->service->getFormData(); + return view('admin.product.products.create', $data); + } + + public function store(Request $request) + { + $data = $this->validateProduct($request); + + $res = $this->service->storeProduct($data, (int)auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + + if (!$res['ok']) { + return redirect()->back()->withInput()->with('toast', ['type' => 'danger', 'title' => '저장 실패', 'message' => $res['message']]); + } + return redirect()->route('admin.products.index')->with('toast', ['type' => 'success', 'title' => '완료', 'message' => $res['message']]); + } + + private function validateProduct(Request $request): array + { + return $request->validate([ + 'category_id' => ['required', 'integer'], + 'name' => ['required', 'string', 'max:150'], + 'thumbnail_media_id' => ['nullable', 'integer'], + 'status' => ['required', 'in:ACTIVE,HIDDEN,SOLD_OUT'], + 'product_type' => ['required', 'in:ONLINE,DELIVERY'], + 'is_buyback_allowed' => ['required', 'in:0,1'], + 'payment_methods' => ['required', 'array', 'min:1'], + 'payment_methods.*' => ['integer'], + 'is_always_on_sale' => ['required', 'in:0,1'], + 'sales_start_at' => ['nullable', 'date'], + 'sales_end_at' => ['nullable', 'date', 'after_or_equal:sales_start_at'], + 'purchase_type' => ['required', 'in:SINGLE,MULTI_QTY,MULTI_SKU'], + 'min_buy_qty' => ['required', 'integer', 'min:1'], + 'max_buy_qty' => ['required', 'integer', 'min:0'], + 'max_buy_amount' => ['required', 'integer', 'min:0'], + 'description' => ['nullable', 'string'], + 'guide' => ['nullable', 'string'], + 'warning' => ['nullable', 'string'], + + // 다중 SKU 밸리데이션 + 'skus' => ['required', 'array', 'min:1'], + 'skus.*.id' => ['nullable', 'integer'], + 'skus.*.name' => ['required', 'string', 'max:50'], + 'skus.*.tax_type' => ['required', 'in:TAX,FREE'], + 'skus.*.face_value' => ['required', 'integer', 'min:0'], + 'skus.*.discount_type' => ['required', 'in:PERCENT,FIXED'], + 'skus.*.discount_value' => ['required', 'integer', 'min:0'], + 'skus.*.sales_method' => ['required', 'in:OWN_PIN,API_LINK'], + 'skus.*.api_provider_id' => ['nullable', 'integer', 'required_if:skus.*.sales_method,API_LINK'], + 'skus.*.api_product_code' => ['nullable', 'string', 'max:50', 'required_if:skus.*.sales_method,API_LINK'], + 'skus.*.is_active' => ['required', 'in:0,1'], + + ], [ + 'payment_methods.required' => '최소 1개 이상의 결제 수단을 선택해주세요.', + 'skus.required' => '최소 1개 이상의 권종을 등록해야 합니다.', + 'skus.*.api_provider_id.required_if' => 'API 연동 시 연동사를 선택해야 합니다.', + 'skus.*.api_product_code.required_if' => 'API 연동 시 상품 코드를 선택해야 합니다.', + ]); + } + + // ... (기존 index, create, store 유지) ... + + public function edit(int $id) + { + $data = $this->service->getEditData($id); + + if (empty($data)) { + return redirect()->route('admin.products.index')->with('toast', ['type' => 'danger', 'title' => '오류', 'message' => '상품을 찾을 수 없습니다.']); + } + + return view('admin.product.products.edit', $data); + } + + public function update(int $id, Request $request) + { + $data = $this->validateProduct($request); + + $res = $this->service->updateProduct($id, $data, (int)auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + + if (!$res['ok']) { + return redirect()->back()->withInput()->with('toast', ['type' => 'danger', 'title' => '수정 실패', 'message' => $res['message']]); + } + + return redirect()->route('admin.products.index')->with('toast', ['type' => 'success', 'title' => '완료', 'message' => $res['message']]); + } + +} diff --git a/app/Http/Controllers/Admin/Product/AdminSaleCodeController.php b/app/Http/Controllers/Admin/Product/AdminSaleCodeController.php new file mode 100644 index 0000000..a4eb8bb --- /dev/null +++ b/app/Http/Controllers/Admin/Product/AdminSaleCodeController.php @@ -0,0 +1,95 @@ +service->getGroupedTree(); + return view('admin.product.salecode.index', ['tree' => $tree]); + } + + // --- Provider --- + public function storeProvider(Request $request) + { + $data = $request->validate([ + 'code' => ['required', 'string', 'max:30'], + 'name' => ['required', 'string', 'max:50'], + 'is_active' => ['required', 'in:0,1'], + ]); + + $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']]); + } + + public function updateProvider(int $id, Request $request) + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:50'], + 'is_active' => ['required', 'in:0,1'], + ]); + + $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']]); + } + + public function destroyProvider(int $id, Request $request) + { + $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']]); + } + + // --- Product Code --- + public function storeCode(Request $request) + { + $data = $request->validate([ + 'provider_id' => ['required', 'integer'], + 'api_code' => ['required', 'string', 'max:50'], + 'name' => ['required', 'string', 'max:100'], + 'is_active' => ['required', 'in:0,1'], + ]); + + $res = $this->service->storeCode($data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]); + } + + public function updateCode(int $id, Request $request) + { + $data = $request->validate([ + 'provider_id' => ['required', 'integer'], + 'api_code' => ['required', 'string', 'max:50'], + 'name' => ['required', 'string', 'max:100'], + 'is_active' => ['required', 'in:0,1'], + ]); + + $res = $this->service->updateCode($id, $data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]); + } + + public function updateCodeSort(Request $request) + { + $data = $request->validate([ + 'ids' => ['required', 'array'], + 'ids.*' => ['integer'], + ]); + + $res = $this->service->updateCodeSort($data['ids'], (int) auth('admin')->id()); + return response()->json($res); + } + + public function destroyCode(int $id, Request $request) + { + $res = $this->service->deleteCode($id, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? ''); + return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]); + } +} diff --git a/app/Http/Controllers/Web/Product/ProductController.php b/app/Http/Controllers/Web/Product/ProductController.php new file mode 100644 index 0000000..32001cc --- /dev/null +++ b/app/Http/Controllers/Web/Product/ProductController.php @@ -0,0 +1,42 @@ +query('search'); // ?search=키워드 추출 + $data = $this->service->getListSceneData($category, $search); + + return view('web.product.list.index', [ + 'pageTitle' => $data['currentCategoryName'], + 'subnavItems' => $data['menuItems'], + 'subnavActive' => null, // 검색 시에는 사이드바 활성화 해제 권장 + 'products' => $data['products'], + 'searchKeyword' => $search + ]); + } + + public function show(int $id) + { + $data = $this->service->getProductDetailData($id); + + if (!$data) abort(404); + + return view('web.product.detail.index', [ + 'product' => $data['product'], + 'skus' => $data['skus'], + 'payments' => $data['payments'], + 'subnavItems' => $data['menuItems'], + 'subnavActive' => $data['product']->category_id, + 'pageTitle' => $data['product']->name, + ]); + } +} diff --git a/app/Repositories/Admin/Product/AdminCategoryRepository.php b/app/Repositories/Admin/Product/AdminCategoryRepository.php new file mode 100644 index 0000000..376485d --- /dev/null +++ b/app/Repositories/Admin/Product/AdminCategoryRepository.php @@ -0,0 +1,69 @@ +orderByRaw('ISNULL(parent_id) DESC') // 1차(parent_id=null)를 먼저 + ->orderBy('parent_id') + ->orderBy('sort_order') + ->get() + ->map(fn($r) => (array)$r) + ->all(); + } + + public function findCategory(int $id): ?object + { + return DB::table('gc_categories')->where('id', $id)->first(); + } + + public function insertCategory(array $data): int + { + $data['created_at'] = now()->format('Y-m-d H:i:s'); + $data['updated_at'] = $data['created_at']; + + return DB::table('gc_categories')->insertGetId($data); + } + + public function updateCategory(int $id, array $data): bool + { + $data['updated_at'] = now()->format('Y-m-d H:i:s'); + return DB::table('gc_categories')->where('id', $id)->update($data) > 0; + } + + public function deleteCategory(int $id): bool + { + return DB::table('gc_categories')->where('id', $id)->delete() > 0; + } + + /** + * 하위 카테고리 개수 확인 (삭제 방어용) + */ + public function countChildren(int $parentId): int + { + return DB::table('gc_categories')->where('parent_id', $parentId)->count(); + } + + /** + * 해당 카테고리에 속한 상품 개수 확인 (삭제 방어용 - 향후 상품 테이블도 gc_products 사용) + */ + public function countProducts(int $categoryId): int + { + return DB::table('gc_products')->where('category_id', $categoryId)->count(); + } + + public function updateSortOrder(int $id, int $sortOrder): bool + { + return DB::table('gc_categories') + ->where('id', $id) + ->update(['sort_order' => $sortOrder]) > 0; + } +} diff --git a/app/Repositories/Admin/Product/AdminFeeRepository.php b/app/Repositories/Admin/Product/AdminFeeRepository.php new file mode 100644 index 0000000..06eea62 --- /dev/null +++ b/app/Repositories/Admin/Product/AdminFeeRepository.php @@ -0,0 +1,65 @@ +orderBy('sort_order') + ->get() + ->map(fn($r) => (array)$r) + ->all(); + } + + public function findPaymentMethod(int $id): ?object + { + return DB::table('gc_payment_methods')->where('id', $id)->first(); + } + + public function insertPaymentMethod(array $data): int + { + $data['created_at'] = now()->format('Y-m-d H:i:s'); + $data['updated_at'] = $data['created_at']; + return DB::table('gc_payment_methods')->insertGetId($data); + } + + public function updatePaymentMethod(int $id, array $data): bool + { + $data['updated_at'] = now()->format('Y-m-d H:i:s'); + return DB::table('gc_payment_methods')->where('id', $id)->update($data) > 0; + } + + public function deletePaymentMethod(int $id): bool + { + return DB::table('gc_payment_methods')->where('id', $id)->delete() > 0; + } + + public function updatePaymentSortOrder(int $id, int $sortOrder): bool + { + return DB::table('gc_payment_methods') + ->where('id', $id) + ->update(['sort_order' => $sortOrder]) > 0; + } + + // ========================================== + // 2. 매입/출금 정책 (Buyback Policy) + // ========================================== + public function getBuybackPolicy(): ?object + { + // 항상 1번 row를 기본 정책으로 사용 + return DB::table('gc_buyback_policies')->where('id', 1)->first(); + } + + public function updateBuybackPolicy(array $data): bool + { + $data['updated_at'] = now()->format('Y-m-d H:i:s'); + return DB::table('gc_buyback_policies')->where('id', 1)->update($data) > 0; + } +} diff --git a/app/Repositories/Admin/Product/AdminMediaRepository.php b/app/Repositories/Admin/Product/AdminMediaRepository.php new file mode 100644 index 0000000..335328f --- /dev/null +++ b/app/Repositories/Admin/Product/AdminMediaRepository.php @@ -0,0 +1,64 @@ +where('folder_name', $filters['folder']); + } + + return $q->orderByDesc('id')->paginate($perPage)->withQueryString(); + } + + /** + * 사용 중인 폴더명 목록 추출 (Select Box용) + */ + public function getDistinctFolders(): array + { + return DB::table('gc_media_library') + ->select('folder_name') + ->distinct() + ->orderBy('folder_name') + ->pluck('folder_name') + ->all(); + } + + public function findMedia(int $id): ?object + { + return DB::table('gc_media_library')->where('id', $id)->first(); + } + + public function insertMedia(array $data): int + { + $data['created_at'] = now()->format('Y-m-d H:i:s'); + $data['updated_at'] = $data['created_at']; + return DB::table('gc_media_library')->insertGetId($data); + } + + public function deleteMedia(int $id): bool + { + return DB::table('gc_media_library')->where('id', $id)->delete() > 0; + } + + public function updateMediaName(int $id, string $name): bool + { + return DB::table('gc_media_library') + ->where('id', $id) + ->update([ + 'name' => $name, + 'updated_at' => now()->format('Y-m-d H:i:s') + ]) > 0; + } +} diff --git a/app/Repositories/Admin/Product/AdminPinRepository.php b/app/Repositories/Admin/Product/AdminPinRepository.php new file mode 100644 index 0000000..24bed2a --- /dev/null +++ b/app/Repositories/Admin/Product/AdminPinRepository.php @@ -0,0 +1,55 @@ +where('sku_id', $skuId); + + if (!empty($filters['status'])) { + $q->where('status', $filters['status']); + } + + // ✨ [추가] 핀 번호 정확도 검색 (해시값 이용) + if (!empty($filters['q'])) { + $searchHash = hash('sha256', trim($filters['q'])); + $q->where('pin_hash', $searchHash); + } + + return $q->orderByDesc('id')->paginate($perPage)->withQueryString(); + } + + /** + * [추가] 핀 재고 통계 가져오기 + */ + public function getPinStats(int $skuId): array + { + $stats = DB::table('gc_pins') + ->select('status', DB::raw('count(*) as cnt')) + ->where('sku_id', $skuId) + ->groupBy('status') + ->pluck('cnt', 'status')->toArray(); + + $total = array_sum($stats); + return [ + 'total' => $total, + 'AVAILABLE' => $stats['AVAILABLE'] ?? 0, + 'SOLD' => $stats['SOLD'] ?? 0, + 'HOLD' => $stats['HOLD'] ?? 0, + 'RECALLED' => $stats['RECALLED'] ?? 0, + 'EXPIRED' => $stats['EXPIRED'] ?? 0, + ]; + } + + public function insertPinsBulk(array $pinsData): int + { + return DB::table('gc_pins')->insertOrIgnore($pinsData); + } +} diff --git a/app/Repositories/Admin/Product/AdminProductRepository.php b/app/Repositories/Admin/Product/AdminProductRepository.php new file mode 100644 index 0000000..b6c0bed --- /dev/null +++ b/app/Repositories/Admin/Product/AdminProductRepository.php @@ -0,0 +1,148 @@ +leftJoin('gc_categories as c2', 'p.category_id', '=', 'c2.id') + ->leftJoin('gc_categories as c1', 'c2.parent_id', '=', 'c1.id') + ->leftJoin('gc_media_library as m', 'p.thumbnail_media_id', '=', 'm.id'); + + if (!empty($filters['cate'])) { + $cateId = (int) $filters['cate']; + $q->where(function ($query) use ($cateId) { + $query->where('p.category_id', $cateId)->orWhere('c2.parent_id', $cateId); + }); + } + if (!empty($filters['status'])) { + $q->where('p.status', $filters['status']); + } + if (!empty($filters['q'])) { + $q->where('p.name', 'like', '%' . $filters['q'] . '%'); + } + + $paginator = $q->select([ + 'p.*', + 'm.file_path as thumbnail_path', + DB::raw("IF(c1.name IS NOT NULL, CONCAT(c1.name, ' > ', c2.name), c2.name) as category_path") + ])->orderByDesc('p.id')->paginate($perPage)->withQueryString(); + + // ✨ [추가] 리스트에 출력된 상품들의 모든 권종(SKU)과 핀 재고 정보 병합 + $productIds = collect($paginator->items())->pluck('id')->toArray(); + if (!empty($productIds)) { + // 권종 정보 및 API 연동사 이름 가져오기 + $skus = DB::table('gc_product_skus as s') + ->leftJoin('gc_api_providers as api', 's.api_provider_id', '=', 'api.id') // ✨ 실제 테이블명으로 변경 + ->select('s.*', 'api.name as provider_name') + ->whereIn('s.product_id', $productIds) + ->orderBy('s.sort_order') + ->get(); + + // 자사핀 재고 카운트 (전체 / 판매대기) + $skuIds = $skus->pluck('id')->toArray(); + $pinStats = []; + if (!empty($skuIds)) { + $pinStats = DB::table('gc_pins') + ->select('sku_id', + DB::raw("COUNT(*) as total_pins"), + DB::raw("SUM(IF(status = 'AVAILABLE', 1, 0)) as available_pins") + ) + ->whereIn('sku_id', $skuIds) + ->groupBy('sku_id') + ->get()->keyBy('sku_id')->toArray(); + } + + // 상품별로 권종 그룹핑 + $skusByProduct = []; + foreach ($skus as $sku) { + $sku->total_pins = $pinStats[$sku->id]->total_pins ?? 0; + $sku->available_pins = $pinStats[$sku->id]->available_pins ?? 0; + $skusByProduct[$sku->product_id][] = $sku; + } + + // 페이지네이터 아이템에 skus 배열 주입 + foreach ($paginator->items() as $item) { + $item->skus = $skusByProduct[$item->id] ?? []; + } + } + + return $paginator; + } + + public function findProduct(int $id): ?object + { + return DB::table('gc_products')->where('id', $id)->first(); + } + + public function insertProduct(array $data): int + { + $data['created_at'] = now()->format('Y-m-d H:i:s'); + $data['updated_at'] = $data['created_at']; + return DB::table('gc_products')->insertGetId($data); + } + + public function insertSkus(array $skusData): bool + { + $now = now()->format('Y-m-d H:i:s'); + foreach ($skusData as &$sku) { + $sku['created_at'] = $now; + $sku['updated_at'] = $now; + } + return DB::table('gc_product_skus')->insert($skusData); + } + + public function updateProduct(int $id, array $data): bool + { + $data['updated_at'] = now()->format('Y-m-d H:i:s'); + return DB::table('gc_products')->where('id', $id)->update($data) > 0; + } + + public function getSkusByProductId(int $productId): array + { + return DB::table('gc_product_skus') + ->where('product_id', $productId) + ->orderBy('sort_order') + ->get() + ->map(fn($r) => (array)$r) + ->toArray(); + } + + public function insertSkuGetId(array $skuData): int + { + $now = now()->format('Y-m-d H:i:s'); + $skuData['created_at'] = $now; + $skuData['updated_at'] = $now; + return DB::table('gc_product_skus')->insertGetId($skuData); + } + + public function updateSku(int $id, array $data): bool + { + $data['updated_at'] = now()->format('Y-m-d H:i:s'); + return DB::table('gc_product_skus')->where('id', $id)->update($data) > 0; + } + + public function deleteSkusNotIn(int $productId, array $keepIds): bool + { + if (empty($keepIds)) { + return DB::table('gc_product_skus')->where('product_id', $productId)->delete() > 0; + } + return DB::table('gc_product_skus') + ->where('product_id', $productId) + ->whereNotIn('id', $keepIds) + ->delete() > 0; + } + + public function deleteProduct(int $id): bool + { + return DB::table('gc_products')->where('id', $id)->delete() > 0; + } +} diff --git a/app/Repositories/Admin/Product/AdminSaleCodeRepository.php b/app/Repositories/Admin/Product/AdminSaleCodeRepository.php new file mode 100644 index 0000000..62847c9 --- /dev/null +++ b/app/Repositories/Admin/Product/AdminSaleCodeRepository.php @@ -0,0 +1,91 @@ +orderBy('sort_order') + ->get() + ->map(fn($r) => (array)$r) + ->all(); + } + + public function findProvider(int $id): ?object + { + return DB::table('gc_api_providers')->where('id', $id)->first(); + } + + public function insertProvider(array $data): int + { + $data['created_at'] = now()->format('Y-m-d H:i:s'); + $data['updated_at'] = $data['created_at']; + return DB::table('gc_api_providers')->insertGetId($data); + } + + public function updateProvider(int $id, array $data): bool + { + $data['updated_at'] = now()->format('Y-m-d H:i:s'); + return DB::table('gc_api_providers')->where('id', $id)->update($data) > 0; + } + + public function deleteProvider(int $id): bool + { + return DB::table('gc_api_providers')->where('id', $id)->delete() > 0; + } + + // ========================================== + // 2. Product Codes (상품 코드 매핑) + // ========================================== + public function getAllCodes(): array + { + return DB::table('gc_api_product_codes') + ->orderBy('provider_id') + ->orderBy('sort_order') + ->get() + ->map(fn($r) => (array)$r) + ->all(); + } + + public function findCode(int $id): ?object + { + return DB::table('gc_api_product_codes')->where('id', $id)->first(); + } + + public function insertCode(array $data): int + { + $data['created_at'] = now()->format('Y-m-d H:i:s'); + $data['updated_at'] = $data['created_at']; + return DB::table('gc_api_product_codes')->insertGetId($data); + } + + public function updateCode(int $id, array $data): bool + { + $data['updated_at'] = now()->format('Y-m-d H:i:s'); + return DB::table('gc_api_product_codes')->where('id', $id)->update($data) > 0; + } + + public function deleteCode(int $id): bool + { + return DB::table('gc_api_product_codes')->where('id', $id)->delete() > 0; + } + + public function updateCodeSortOrder(int $id, int $sortOrder): bool + { + return DB::table('gc_api_product_codes') + ->where('id', $id) + ->update(['sort_order' => $sortOrder]) > 0; + } + + public function countCodesByProvider(int $providerId): int + { + return DB::table('gc_api_product_codes')->where('provider_id', $providerId)->count(); + } +} diff --git a/app/Repositories/Product/ProductRepository.php b/app/Repositories/Product/ProductRepository.php new file mode 100644 index 0000000..b1a47a3 --- /dev/null +++ b/app/Repositories/Product/ProductRepository.php @@ -0,0 +1,111 @@ +where('is_active', 1) + ->orderBy('sort_order', 'asc') + ->get(); + } + + /** + * 카테고리별/전체 상품 목록 조회 (판매 기간 및 상태 검증 포함) + */ + + // app/Repositories/Product/ProductRepository.php + + public function getActiveProducts($categoryIdOrSlug = null, $search = null) + { + $now = now(); + $query = DB::table('gc_products as p') + ->select('p.*', 'm.file_path as thumb_path') + ->leftJoin('gc_media_library as m', 'p.thumbnail_media_id', '=', 'm.id') + ->where('p.status', 'ACTIVE'); + + // 1. 검색어가 있을 경우 (통합 검색) + if ($search) { + $query->where(function ($q) use ($search) { + // 상품명 검색 + $q->where('p.name', 'LIKE', "%{$search}%"); + + // 카테고리 연관 검색 (이름, 슬러그, 해시태그 키워드) + $q->orWhereExists(function ($sub) use ($search) { + $sub->select(DB::raw(1)) + ->from('gc_categories as c') + ->whereColumn('c.id', 'p.category_id') + ->where(function ($sq) use ($search) { + $sq->where('c.name', 'LIKE', "%{$search}%") + ->orWhere('c.slug', 'LIKE', "%{$search}%") + ->orWhere('c.search_keywords', 'LIKE', "%{$search}%"); // # 제거된 검색어도 LIKE로 매칭 + }); + }); + }); + } + + if ($categoryIdOrSlug && !$search) { + $categoryQuery = DB::table('gc_categories'); + if (is_numeric($categoryIdOrSlug)) { + $categoryQuery->where('id', $categoryIdOrSlug); + } else { + $categoryQuery->where('slug', $categoryIdOrSlug); + } + $targetCategory = $categoryQuery->first(); + + if ($targetCategory) { + $categoryIds = DB::table('gc_categories') + ->where('id', $targetCategory->id) + ->orWhere('parent_id', $targetCategory->id) + ->pluck('id'); + $query->whereIn('p.category_id', $categoryIds); + } + } + + return $query->orderBy('p.id', 'desc')->get(); + } + + public function getProductById(int $id) + { + return DB::table('gc_products as p') + ->select('p.*', 'm.file_path as thumb_path') + ->leftJoin('gc_media_library as m', 'p.thumbnail_media_id', '=', 'm.id') + ->where('p.id', $id) + ->where('p.status', 'ACTIVE') + ->first(); + } + + /** + * 상품에 속한 권종(SKU) 목록 조회 + */ + public function getSkusByProductId(int $productId) + { + return DB::table('gc_product_skus') + ->where('product_id', $productId) + ->where('is_active', 1) + ->orderBy('sort_order', 'asc') + ->get(); + } + + /** + * 특정 결제 수단 정보 조회 + */ + public function getPaymentMethodsByIds(array $ids) + { + if (empty($ids)) return collect(); + + return DB::table('gc_payment_methods') + ->whereIn('id', $ids) + ->where('is_active', 1) + ->orderBy('sort_order', 'asc') + ->get(); + } +} diff --git a/app/Services/Admin/Product/AdminCategoryService.php b/app/Services/Admin/Product/AdminCategoryService.php new file mode 100644 index 0000000..f532db5 --- /dev/null +++ b/app/Services/Admin/Product/AdminCategoryService.php @@ -0,0 +1,156 @@ +repo->getAllCategories(); + $tree = []; + $children = []; + + // 1차, 2차 분리 + foreach ($all as $c) { + if (empty($c['parent_id'])) { + $c['children'] = []; + $tree[$c['id']] = $c; + } else { + $children[$c['parent_id']][] = $c; + } + } + + // 조립 + foreach ($children as $parentId => $childArray) { + if (isset($tree[$parentId])) { + $tree[$parentId]['children'] = $childArray; + } + } + + return array_values($tree); + } + + public function storeCategory(array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) { + $data = [ + 'parent_id' => !empty($input['parent_id']) ? (int)$input['parent_id'] : null, + 'name' => trim($input['name']), + 'slug' => strtolower(trim($input['slug'])), + 'search_keywords' => trim($input['search_keywords'] ?? ''), + 'sort_order' => 0, + 'is_active' => (int)($input['is_active'] ?? 1), + ]; + + $newId = $this->repo->insertCategory($data); + + // 감사 로그 + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.category.create', + targetType: 'category', + targetId: $newId, + before: null, + after: $data, + ip: $ip, + ua: $ua, + ); + + return ['ok' => true, 'message' => '카테고리가 등록되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '저장 중 오류가 발생했습니다.']; + } + } + + public function updateCategory(int $id, array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) { + $before = $this->repo->findCategory($id); + if (!$before) return ['ok' => false, 'message' => '카테고리를 찾을 수 없습니다.']; + + $data = [ + 'name' => trim($input['name'] ?? $before->name), + 'is_active' => isset($input['is_active']) ? (int)$input['is_active'] : $before->is_active, + 'slug' => strtolower(trim($input['slug'])), + 'search_keywords' => trim($input['search_keywords'] ?? ''), + ]; + + $this->repo->updateCategory($id, $data); + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.category.update', + targetType: 'category', + targetId: $id, + before: (array)$before, + after: array_merge((array)$before, $data), + ip: $ip, + ua: $ua, + ); + + return ['ok' => true, 'message' => '변경되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다.']; + } + } + + public function deleteCategory(int $id, int $actorAdminId, string $ip, string $ua): array + { + $before = $this->repo->findCategory($id); + if (!$before) return ['ok' => false, 'message' => '존재하지 않는 카테고리입니다.']; + + // 삭제 방어 1: 하위 카테고리가 있는지 + if (empty($before->parent_id) && $this->repo->countChildren($id) > 0) { + return ['ok' => false, 'message' => '하위 2차 카테고리가 존재하여 삭제할 수 없습니다.']; + } + + $this->repo->deleteCategory($id); + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.category.delete', + targetType: 'category', + targetId: $id, + before: (array)$before, + after: null, + ip: $ip, + ua: $ua, + ); + + return ['ok' => true, 'message' => '삭제되었습니다.']; + } + + /** + * [추가] AJAX 정렬 순서 일괄 업데이트 + */ + public function updateSort(array $ids, int $actorAdminId): array + { + try { + DB::transaction(function () use ($ids) { + // 배열의 인덱스(0, 1, 2...)를 기준으로 +1 하여 sort_order 업데이트 + foreach ($ids as $index => $id) { + $this->repo->updateSortOrder((int)$id, $index + 1); + } + }); + return ['ok' => true]; + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '정렬 중 오류가 발생했습니다.']; + } + } +} diff --git a/app/Services/Admin/Product/AdminFeeService.php b/app/Services/Admin/Product/AdminFeeService.php new file mode 100644 index 0000000..9c1ecee --- /dev/null +++ b/app/Services/Admin/Product/AdminFeeService.php @@ -0,0 +1,127 @@ + $this->repo->getPaymentMethods(), + 'buybackPolicy' => $this->repo->getBuybackPolicy(), + ]; + } + + public function updateBuybackPolicy(array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + $before = $this->repo->getBuybackPolicy(); + + $data = [ + 'customer_fee_rate' => (float)$input['customer_fee_rate'], + 'bank_fee_type' => $input['bank_fee_type'], + 'bank_fee_value' => (float)$input['bank_fee_value'], + ]; + + $this->repo->updateBuybackPolicy($data); + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.fee.buyback.update', + targetType: 'buyback_policy', + targetId: 1, + before: (array)$before, + after: array_merge((array)$before, $data), + ip: $ip, + ua: $ua, + ); + + return ['ok' => true, 'message' => '매입(출금) 정책이 업데이트되었습니다.']; + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '출금 정책 저장 중 오류가 발생했습니다.']; + } + } + + public function storePaymentMethod(array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) { + $data = [ + 'code' => strtoupper(trim($input['code'])), + 'name' => trim($input['name']), + 'display_name' => trim($input['display_name']), + 'customer_fee_rate' => (float)$input['customer_fee_rate'], + 'pg_fee_rate' => (float)$input['pg_fee_rate'], + 'is_active' => (int)$input['is_active'], + 'sort_order' => 0, // 기본값 + ]; + + $newId = $this->repo->insertPaymentMethod($data); + + $this->audit->log($actorAdminId, 'admin.fee.payment.create', 'payment_method', $newId, null, $data, $ip, $ua); + return ['ok' => true, 'message' => '결제 수단이 등록되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '결제수단 코드가 중복되거나 저장 오류가 발생했습니다.']; + } + } + + public function updatePaymentMethod(int $id, array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) { + $before = $this->repo->findPaymentMethod($id); + if (!$before) return ['ok' => false, 'message' => '결제 수단을 찾을 수 없습니다.']; + + $data = [ + 'name' => trim($input['name']), + 'display_name' => trim($input['display_name']), + 'customer_fee_rate' => (float)$input['customer_fee_rate'], + 'pg_fee_rate' => (float)$input['pg_fee_rate'], + 'is_active' => (int)$input['is_active'], + ]; + + $this->repo->updatePaymentMethod($id, $data); + + $this->audit->log($actorAdminId, 'admin.fee.payment.update', 'payment_method', $id, (array)$before, array_merge((array)$before, $data), $ip, $ua); + return ['ok' => true, 'message' => '결제 수단이 수정되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다.']; + } + } + + public function updatePaymentSort(array $ids, int $actorAdminId): array + { + try { + DB::transaction(function () use ($ids) { + foreach ($ids as $index => $id) { + $this->repo->updatePaymentSortOrder((int)$id, $index + 1); + } + }); + return ['ok' => true]; + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '정렬 중 오류가 발생했습니다.']; + } + } + + public function deletePaymentMethod(int $id, int $actorAdminId, string $ip, string $ua): array + { + $before = $this->repo->findPaymentMethod($id); + if (!$before) return ['ok' => false, 'message' => '결제 수단을 찾을 수 없습니다.']; + + $this->repo->deletePaymentMethod($id); + $this->audit->log($actorAdminId, 'admin.fee.payment.delete', 'payment_method', $id, (array)$before, null, $ip, $ua); + + return ['ok' => true, 'message' => '삭제되었습니다.']; + } +} diff --git a/app/Services/Admin/Product/AdminMediaService.php b/app/Services/Admin/Product/AdminMediaService.php new file mode 100644 index 0000000..da7bdfc --- /dev/null +++ b/app/Services/Admin/Product/AdminMediaService.php @@ -0,0 +1,189 @@ +manager = new ImageManager(new Driver()); + } + + /** + * 미디어 목록 조회 (필터 및 페이징) + */ + public function list(array $filters): array + { + return [ + 'page' => $this->repo->paginateMedia($filters), + 'folders' => $this->repo->getDistinctFolders(), + 'currentFolder' => $filters['folder'] ?? '', + ]; + } + + /** + * 이미지 업로드 및 WebP 변환 저장 + * * @param array $input 폴더명 등 입력값 + * @param UploadedFile $file 업로드된 파일 객체 + * @param string|null $customName 관리자가 지정한 이미지 이름 (없으면 원본명 사용) + * @param int $fileIndex 다중 업로드 시 순번 (이름 뒤에 _1, _2 붙이기 위함) + */ + public function uploadImage(array $input, UploadedFile $file, ?string $customName, int $fileIndex, int $actorAdminId, string $ip, string $ua): array + { + try { + // 1. 폴더명 보안 처리 (영문, 숫자, 언더바, 하이픈만 허용) + $folderName = trim($input['folder_name'] ?? 'default'); + $folderName = preg_replace('/[^a-zA-Z0-9_\-]/', '', $folderName); + if (empty($folderName)) $folderName = 'default'; + + $originalName = $file->getClientOriginalName(); + + // 2. 이미지 처리 (WebP 변환) + // Intervention Image를 통해 파일을 읽고 WebP 포맷(퀄리티 80)으로 인코딩 + $image = $this->manager->read($file); + $encoded = $image->toWebp(quality: 80); + + // 3. 저장할 파일명 생성 (랜덤 해시 + .webp) + $hashName = Str::random(40) . '.webp'; + $savePath = "product/{$folderName}/{$hashName}"; + + // 4. 스토리지 저장 (public 디스크) + // put() 메소드를 사용하여 변환된 바이너리 데이터를 저장 + $isSaved = Storage::disk('public')->put($savePath, (string) $encoded); + + if (!$isSaved) { + return ['ok' => false, 'message' => '스토리지 파일 저장에 실패했습니다.']; + } + + // 5. 관리용 이름(Name) 결정 + if (!empty($customName)) { + // 다중 파일인 경우 뒤에 순번을 붙임 (예: 문화상품권_1) + $finalName = $fileIndex > 0 ? $customName . '_' . $fileIndex : $customName; + } else { + // 이름 지정 안 함 -> 원본 파일명에서 확장자 제거하고 사용 + $finalName = pathinfo($originalName, PATHINFO_FILENAME); + } + + // 6. DB 저장 데이터 준비 + $data = [ + 'folder_name' => $folderName, + 'name' => $finalName, + 'original_name' => $originalName, + 'file_name' => $hashName, + 'file_path' => '/storage/' . $savePath, // 웹 접근 경로 + 'file_size' => strlen((string) $encoded), // 변환된 파일 크기 + 'file_ext' => 'webp', // 확장자는 무조건 webp + ]; + + // 7. 트랜잭션 DB 저장 및 로그 + DB::transaction(function () use ($data, $actorAdminId, $ip, $ua) { + $newId = $this->repo->insertMedia($data); + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.media.upload', + targetType: 'media_library', + targetId: $newId, + before: null, + after: $data, + ip: $ip, + ua: $ua + ); + }); + + return ['ok' => true, 'message' => '업로드 성공']; + + } catch (\Throwable $e) { + // 이미지 변환 실패(엑셀 파일 등) 혹은 기타 오류 처리 + return ['ok' => false, 'message' => '이미지 처리 중 오류 발생: ' . $e->getMessage()]; + } + } + + /** + * 이미지 이름(관리용) 수정 + */ + public function updateMediaName(int $id, string $newName, int $actorAdminId, string $ip, string $ua): array + { + $before = $this->repo->findMedia($id); + if (!$before) return ['ok' => false, 'message' => '이미지를 찾을 수 없습니다.']; + + try { + DB::transaction(function () use ($id, $newName, $before, $actorAdminId, $ip, $ua) { + $this->repo->updateMediaName($id, trim($newName)); + + $after = (array)$before; + $after['name'] = trim($newName); + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.media.rename', + targetType: 'media_library', + targetId: $id, + before: (array)$before, + after: $after, + ip: $ip, + ua: $ua + ); + }); + + return ['ok' => true, 'message' => '이름이 변경되었습니다.']; + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '이름 변경 중 오류가 발생했습니다.']; + } + } + + /** + * 이미지 삭제 (DB + 물리 파일) + */ + public function deleteImage(int $id, int $actorAdminId, string $ip, string $ua): array + { + $media = $this->repo->findMedia($id); + if (!$media) return ['ok' => false, 'message' => '이미지를 찾을 수 없습니다.']; + + try { + // 1. DB 삭제 및 로그 (트랜잭션) + DB::transaction(function () use ($id, $media, $actorAdminId, $ip, $ua) { + $this->repo->deleteMedia($id); + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.media.delete', + targetType: 'media_library', + targetId: $id, + before: (array)$media, + after: null, + ip: $ip, + ua: $ua + ); + }); + + // 2. 물리 파일 삭제 (DB 삭제 성공 시 실행) + // /storage/product/... -> product/... 로 변환하여 삭제 + $storagePath = str_replace('/storage/', '', $media->file_path); + + if (Storage::disk('public')->exists($storagePath)) { + Storage::disk('public')->delete($storagePath); + } + + return ['ok' => true, 'message' => '이미지가 삭제되었습니다.']; + + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '삭제 처리 중 오류가 발생했습니다.']; + } + } +} diff --git a/app/Services/Admin/Product/AdminPinService.php b/app/Services/Admin/Product/AdminPinService.php new file mode 100644 index 0000000..cebfbe5 --- /dev/null +++ b/app/Services/Admin/Product/AdminPinService.php @@ -0,0 +1,169 @@ + $this->repo->paginatePins($skuId, $filters), + 'stats' => $this->repo->getPinStats($skuId), + ]; + } + + public function recallPins(int $skuId, string $amountType, int $customAmount, string $zipPassword): array + { + // 1. 회수할 수량 결정 + $limit = ($amountType === 'ALL') ? PHP_INT_MAX : $customAmount; + if ($limit <= 0) return ['ok' => false, 'message' => '회수할 수량을 1 이상 입력해주세요.']; + + DB::beginTransaction(); + try { + // 2. 가장 나중에 등록된(ID가 큰) AVAILABLE 핀들 가져오기 (LIFO) + $pinsToRecall = DB::table('gc_pins') + ->where('sku_id', $skuId) + ->where('status', 'AVAILABLE') + ->orderByDesc('id') + ->limit($limit) + ->get(); + + if ($pinsToRecall->isEmpty()) { + DB::rollBack(); + return ['ok' => false, 'message' => '회수할 수 있는 판매대기(AVAILABLE) 핀이 없습니다.']; + } + + // 3. 상태를 'RECALLED'로 업데이트 + $pinIds = $pinsToRecall->pluck('id')->toArray(); + DB::table('gc_pins')->whereIn('id', $pinIds)->update([ + 'status' => 'RECALLED', + 'updated_at' => now()->format('Y-m-d H:i:s') + ]); + + // 4. 복호화하여 CSV 데이터 텍스트 생성 + // 엑셀에서 한글 깨짐 방지를 위해 BOM(\xEF\xBB\xBF) 추가 + $csvData = "\xEF\xBB\xBF" . "ID,PIN_CODE,FACE_VALUE,BUY_PRICE\n"; + foreach($pinsToRecall as $pin) { + try { + $decrypted = \Illuminate\Support\Facades\Crypt::decryptString($pin->pin_code); + $csvData .= "{$pin->id},{$decrypted},{$pin->face_value},{$pin->buy_price}\n"; + } catch (\Exception $e) { + $csvData .= "{$pin->id},DECRYPT_ERROR,{$pin->face_value},{$pin->buy_price}\n"; + } + } + + // 5. ZIP 파일 생성 (라라벨 storage/app/public 임시 저장) + $fileName = "recalled_pins_sku_{$skuId}_" . date('YmdHis') . ".zip"; + $zipPath = storage_path('app/public/' . $fileName); + + $zip = new \ZipArchive(); + if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true) { + $zip->addFromString('recalled_pins.csv', $csvData); + // 비밀번호 암호화 세팅 (PHP 7.2 이상 지원) + $zip->setPassword($zipPassword); + $zip->setEncryptionName('recalled_pins.csv', \ZipArchive::EM_AES_256); + $zip->close(); + } else { + DB::rollBack(); + return ['ok' => false, 'message' => 'ZIP 파일 생성에 실패했습니다. (서버의 php-zip 확장을 확인하세요)']; + } + + DB::commit(); + return ['ok' => true, 'file_path' => $zipPath, 'file_name' => $fileName, 'count' => count($pinIds)]; + + } catch (\Throwable $e) { + DB::rollBack(); + return ['ok' => false, 'message' => '회수 처리 중 오류가 발생했습니다: ' . $e->getMessage()]; + } + } + + /** + * 텍스트 대량 핀 등록 로직 + */ + public function bulkRegisterPins(int $productId, int $skuId, string $bulkText, string $expiryDate = null): array + { + // 1. 텍스트를 줄바꿈 단위로 분리 (빈 줄 제거) + $lines = array_filter(array_map('trim', explode("\n", $bulkText))); + + if (empty($lines)) { + return ['ok' => false, 'message' => '입력된 핀 정보가 없습니다.']; + } + + $insertData = []; + $now = now()->format('Y-m-d H:i:s'); + $successCount = 0; + $duplicateCount = 0; + $errorLines = 0; + + foreach ($lines as $line) { + // 2. 콤마(,) 또는 탭(\t)으로 데이터 분리 (예: 핀번호, 정액, 원가) + // 정규식을 사용해 콤마나 탭 여러 개를 하나로 취급 + $parts = preg_split('/[\t,]+/', $line); + + // 데이터 파싱 (최소한 핀번호는 있어야 함) + $pinCode = trim($parts[0] ?? ''); + if (empty($pinCode)) continue; + + $faceValue = isset($parts[1]) ? (int)preg_replace('/[^0-9]/', '', $parts[1]) : 0; + $buyPrice = isset($parts[2]) ? (int)preg_replace('/[^0-9]/', '', $parts[2]) : 0; + + // 3. 마진율 계산 (정액금액이 0보다 클 때만 계산) + $marginRate = 0.00; + if ($faceValue > 0) { + $marginRate = round((($faceValue - $buyPrice) / $faceValue) * 100, 2); + } + + // 4. 보안 처리 (암호화 및 해시) + $encryptedPin = Crypt::encryptString($pinCode); + $pinHash = hash('sha256', $pinCode); // 중복 검사용 해시 + + $insertData[] = [ + 'product_id' => $productId, + 'sku_id' => $skuId, + 'pin_code' => $encryptedPin, + 'pin_hash' => $pinHash, + 'status' => 'AVAILABLE', + 'face_value' => $faceValue, + 'buy_price' => $buyPrice, + 'margin_rate' => $marginRate, + 'expiry_date' => $expiryDate ?: null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + // 5. 500개씩 청크(Chunk)로 나누어 일괄 Insert (서버 과부하 방지) + DB::beginTransaction(); + try { + $chunks = array_chunk($insertData, 500); + $totalInserted = 0; + + foreach ($chunks as $chunk) { + // insertOrIgnore 덕분에 중복 핀은 무시되고 성공한 갯수만 반환됨 + $insertedRows = $this->repo->insertPinsBulk($chunk); + $totalInserted += $insertedRows; + } + + DB::commit(); + + $duplicateCount = count($insertData) - $totalInserted; + + return [ + 'ok' => true, + 'message' => "총 {$totalInserted}개의 핀이 등록되었습니다. (중복 제외: {$duplicateCount}개)" + ]; + + } catch (\Throwable $e) { + DB::rollBack(); + return ['ok' => false, 'message' => '대량 등록 중 DB 오류가 발생했습니다: ' . $e->getMessage()]; + } + } +} diff --git a/app/Services/Admin/Product/AdminProductService.php b/app/Services/Admin/Product/AdminProductService.php new file mode 100644 index 0000000..03624ad --- /dev/null +++ b/app/Services/Admin/Product/AdminProductService.php @@ -0,0 +1,226 @@ + $this->repo->paginateProducts($filters), + 'categories' => $this->categoryService->getCategoryTree(), + ]; + } + + public function getFormData(): array + { + return [ + 'categories' => $this->categoryService->getCategoryTree(), + 'providers' => $this->saleCodeService->getGroupedTree(), + 'payments' => DB::table('gc_payment_methods')->where('is_active', 1)->orderBy('sort_order')->get()->toArray(), + 'folders' => DB::table('gc_media_library')->select('folder_name')->distinct()->orderBy('folder_name')->pluck('folder_name')->toArray(), + ]; + } + + public function storeProduct(array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) { + // 1. 상품(부모) 데이터 준비 + $productData = [ + 'category_id' => (int)$input['category_id'], + 'name' => trim($input['name']), + 'thumbnail_media_id' => !empty($input['thumbnail_media_id']) ? (int)$input['thumbnail_media_id'] : null, + 'purchase_type' => $input['purchase_type'] ?? 'MULTI_SKU', + 'min_buy_qty' => (int)($input['min_buy_qty'] ?? 1), + 'max_buy_qty' => (int)($input['max_buy_qty'] ?? 0), + 'max_buy_amount' => (int)($input['max_buy_amount'] ?? 0), + 'product_type' => $input['product_type'] ?? 'ONLINE', + 'allowed_payments' => json_encode($input['payment_methods'] ?? []), // 결제수단 JSON 저장 + 'is_buyback_allowed' => (int)($input['is_buyback_allowed'] ?? 0), + 'status' => $input['status'] ?? 'HIDDEN', + 'is_always_on_sale' => (int)$input['is_always_on_sale'], + 'sales_start_at' => $input['is_always_on_sale'] == 0 ? ($input['sales_start_at'] ?? null) : null, + 'sales_end_at' => $input['is_always_on_sale'] == 0 ? ($input['sales_end_at'] ?? null) : null, + 'description' => trim($input['description'] ?? ''), + 'guide' => trim($input['guide'] ?? ''), + 'warning' => trim($input['warning'] ?? ''), + ]; + + $newProductId = $this->repo->insertProduct($productData); + + // 2. 권종(SKU) 데이터 준비 + $skusData = []; + if (!empty($input['skus']) && is_array($input['skus'])) { + foreach ($input['skus'] as $index => $sku) { + $faceValue = (int)$sku['face_value']; + $discountType = $sku['discount_type']; + $discountValue = (int)$sku['discount_value']; + + // 할인율/정액할인 최종가 계산 로직 분기 + if ($discountType === 'PERCENT') { + $finalPrice = (int)($faceValue - ($faceValue * ($discountValue / 100))); + } else { + $finalPrice = (int)($faceValue - $discountValue); + } + + // 최소 0원 방어 + if ($finalPrice < 0) $finalPrice = 0; + + $salesMethod = $sku['sales_method']; + + $skusData[] = [ + 'product_id' => $newProductId, + 'name' => trim($sku['name']), + 'tax_type' => $sku['tax_type'] ?? 'TAX', + 'face_value' => $faceValue, + 'discount_type' => $discountType, + 'discount_value' => $discountValue, + 'final_price' => $finalPrice, + 'sales_method' => $salesMethod, + 'api_provider_id' => $salesMethod === 'API_LINK' ? (int)$sku['api_provider_id'] : null, + 'api_product_code' => $salesMethod === 'API_LINK' ? trim($sku['api_product_code']) : null, + 'is_active' => (int)$sku['is_active'], + 'sort_order' => $index + 1, + ]; + } + $this->repo->insertSkus($skusData); + } + + $this->audit->log($actorAdminId, 'admin.product.create', 'product', $newProductId, null, ['product' => $productData, 'skus' => $skusData], $ip, $ua); + + return ['ok' => true, 'message' => '상품 및 권종이 성공적으로 등록되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '저장 중 오류가 발생했습니다: ' . $e->getMessage()]; + } + } + + public function getEditData(int $id): array + { + $product = $this->repo->findProduct($id); + if (!$product) return []; + + $skus = $this->repo->getSkusByProductId($id); + + // JSON으로 저장된 결제수단 배열로 복원 + $product->allowed_payments = json_decode($product->allowed_payments ?? '[]', true); + + // 썸네일 정보 가져오기 + $thumbnail = null; + if ($product->thumbnail_media_id) { + $thumbnail = DB::table('gc_media_library')->where('id', $product->thumbnail_media_id)->first(); + } + + // 마스터 데이터(카테고리, 연동사)와 합쳐서 반환 + return array_merge($this->getFormData(), [ + 'product' => $product, + 'skus' => $skus, + 'thumbnail' => $thumbnail + ]); + } + + + public function updateProduct(int $id, array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) { + $before = $this->repo->findProduct($id); + if (!$before) return ['ok' => false, 'message' => '상품을 찾을 수 없습니다.']; + + // 1. 부모 상품 업데이트 + $productData = [ + 'category_id' => (int)$input['category_id'], + 'name' => trim($input['name']), + 'thumbnail_media_id' => !empty($input['thumbnail_media_id']) ? (int)$input['thumbnail_media_id'] : null, + 'purchase_type' => $input['purchase_type'] ?? 'MULTI_SKU', + 'min_buy_qty' => (int)($input['min_buy_qty'] ?? 1), + 'max_buy_qty' => (int)($input['max_buy_qty'] ?? 0), + 'max_buy_amount' => (int)($input['max_buy_amount'] ?? 0), + 'product_type' => $input['product_type'] ?? 'ONLINE', + 'allowed_payments' => json_encode($input['payment_methods'] ?? []), + 'is_buyback_allowed' => (int)($input['is_buyback_allowed'] ?? 0), + 'status' => $input['status'] ?? 'HIDDEN', + 'is_always_on_sale' => (int)$input['is_always_on_sale'], + 'sales_start_at' => $input['is_always_on_sale'] == 0 ? ($input['sales_start_at'] ?? null) : null, + 'sales_end_at' => $input['is_always_on_sale'] == 0 ? ($input['sales_end_at'] ?? null) : null, + 'description' => trim($input['description'] ?? ''), + 'guide' => trim($input['guide'] ?? ''), + 'warning' => trim($input['warning'] ?? ''), + ]; + + $this->repo->updateProduct($id, $productData); + + // 2. 권종(SKU) 업데이트 동기화 (Sync) + $keepSkuIds = []; // 유지/수정된 권종 ID 보관용 + + if (!empty($input['skus']) && is_array($input['skus'])) { + foreach ($input['skus'] as $index => $sku) { + $skuId = !empty($sku['id']) ? (int)$sku['id'] : null; + + $faceValue = (int)$sku['face_value']; + $discountType = $sku['discount_type']; + $discountValue = (int)$sku['discount_value']; + + // 최종가 계산 + $finalPrice = $discountType === 'PERCENT' + ? (int)($faceValue - ($faceValue * ($discountValue / 100))) + : (int)($faceValue - $discountValue); + if ($finalPrice < 0) $finalPrice = 0; + + $salesMethod = $sku['sales_method']; + + $skuData = [ + 'product_id' => $id, + 'name' => trim($sku['name']), + 'tax_type' => $sku['tax_type'] ?? 'TAX', + 'face_value' => $faceValue, + 'discount_type' => $discountType, + 'discount_value' => $discountValue, + 'final_price' => $finalPrice, + 'sales_method' => $salesMethod, + 'api_provider_id' => $salesMethod === 'API_LINK' ? (int)$sku['api_provider_id'] : null, + 'api_product_code' => $salesMethod === 'API_LINK' ? trim($sku['api_product_code']) : null, + 'is_active' => (int)$sku['is_active'], + 'sort_order' => $index + 1, + ]; + + if ($skuId) { + // 기존에 있던 권종이면 Update + $this->repo->updateSku($skuId, $skuData); + $keepSkuIds[] = $skuId; + } else { + // 수정 폼에서 새로 추가된 권종이면 Insert + $newSkuId = $this->repo->insertSkuGetId($skuData); + $keepSkuIds[] = $newSkuId; + } + } + } + + // 3. 삭제 처리 (폼에서 [X 삭제] 버튼으로 지운 권종들) + $this->repo->deleteSkusNotIn($id, $keepSkuIds); + + $this->audit->log($actorAdminId, 'admin.product.update', 'product', $id, (array)$before, $productData, $ip, $ua); + + return ['ok' => true, 'message' => '상품 및 권종 정보가 성공적으로 수정되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다: ' . $e->getMessage()]; + } + } + + +} diff --git a/app/Services/Admin/Product/AdminSaleCodeService.php b/app/Services/Admin/Product/AdminSaleCodeService.php new file mode 100644 index 0000000..beec0a0 --- /dev/null +++ b/app/Services/Admin/Product/AdminSaleCodeService.php @@ -0,0 +1,169 @@ + 하위 상품코드) + */ + public function getGroupedTree(): array + { + $providers = $this->repo->getAllProviders(); + $codes = $this->repo->getAllCodes(); + + $tree = []; + foreach ($providers as $p) { + $p['children'] = []; + $tree[$p['id']] = $p; + } + + foreach ($codes as $c) { + if (isset($tree[$c['provider_id']])) { + $tree[$c['provider_id']]['children'][] = $c; + } + } + + return array_values($tree); + } + + // --- Provider --- + public function storeProvider(array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) { + $data = [ + 'code' => strtoupper(trim($input['code'])), + 'name' => trim($input['name']), + 'is_active' => (int)$input['is_active'], + 'sort_order' => 0, + ]; + + $newId = $this->repo->insertProvider($data); + $this->audit->log($actorAdminId, 'admin.sale_code.provider.create', 'api_provider', $newId, null, $data, $ip, $ua); + + return ['ok' => true, 'message' => '연동사가 등록되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '연동사 코드가 중복되거나 저장 오류가 발생했습니다.']; + } + } + + public function updateProvider(int $id, array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) { + $before = $this->repo->findProvider($id); + if (!$before) return ['ok' => false, 'message' => '연동사를 찾을 수 없습니다.']; + + $data = [ + 'name' => trim($input['name']), + 'is_active' => (int)$input['is_active'], + ]; + + $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); + + return ['ok' => true, 'message' => '연동사가 수정되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다.']; + } + } + + public function deleteProvider(int $id, int $actorAdminId, string $ip, string $ua): array + { + $before = $this->repo->findProvider($id); + if (!$before) return ['ok' => false, 'message' => '존재하지 않는 연동사입니다.']; + + if ($this->repo->countCodesByProvider($id) > 0) { + return ['ok' => false, 'message' => '하위 상품 코드가 존재하여 삭제할 수 없습니다. 상품 코드를 먼저 삭제해주세요.']; + } + + $this->repo->deleteProvider($id); + $this->audit->log($actorAdminId, 'admin.sale_code.provider.delete', 'api_provider', $id, (array)$before, null, $ip, $ua); + + return ['ok' => true, 'message' => '삭제되었습니다.']; + } + + // --- Product Code --- + public function storeCode(array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) { + $data = [ + 'provider_id' => (int)$input['provider_id'], + 'api_code' => strtoupper(trim($input['api_code'])), + 'name' => trim($input['name']), + 'is_active' => (int)$input['is_active'], + 'sort_order' => 0, + ]; + + $newId = $this->repo->insertCode($data); + $this->audit->log($actorAdminId, 'admin.sale_code.code.create', 'api_code', $newId, null, $data, $ip, $ua); + + return ['ok' => true, 'message' => '상품 코드가 등록되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '해당 연동사에 중복된 코드가 있거나 저장 오류가 발생했습니다.']; + } + } + + public function updateCode(int $id, array $input, int $actorAdminId, string $ip, string $ua): array + { + try { + return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) { + $before = $this->repo->findCode($id); + if (!$before) return ['ok' => false, 'message' => '상품 코드를 찾을 수 없습니다.']; + + $data = [ + 'provider_id' => (int)$input['provider_id'], + 'api_code' => strtoupper(trim($input['api_code'])), + 'name' => trim($input['name']), + 'is_active' => (int)$input['is_active'], + ]; + + $this->repo->updateCode($id, $data); + $this->audit->log($actorAdminId, 'admin.sale_code.code.update', 'api_code', $id, (array)$before, array_merge((array)$before, $data), $ip, $ua); + + return ['ok' => true, 'message' => '상품 코드가 수정되었습니다.']; + }); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '수정 중 중복 오류가 발생했습니다.']; + } + } + + public function updateCodeSort(array $ids, int $actorAdminId): array + { + try { + DB::transaction(function () use ($ids) { + foreach ($ids as $index => $id) { + $this->repo->updateCodeSortOrder((int)$id, $index + 1); + } + }); + return ['ok' => true]; + } catch (\Throwable $e) { + return ['ok' => false, 'message' => '정렬 중 오류가 발생했습니다.']; + } + } + + public function deleteCode(int $id, int $actorAdminId, string $ip, string $ua): array + { + $before = $this->repo->findCode($id); + if (!$before) return ['ok' => false, 'message' => '상품 코드를 찾을 수 없습니다.']; + + $this->repo->deleteCode($id); + $this->audit->log($actorAdminId, 'admin.sale_code.code.delete', 'api_code', $id, (array)$before, null, $ip, $ua); + + return ['ok' => true, 'message' => '상품 코드가 삭제되었습니다.']; + } +} diff --git a/app/Services/Product/ProductService.php b/app/Services/Product/ProductService.php new file mode 100644 index 0000000..61c9917 --- /dev/null +++ b/app/Services/Product/ProductService.php @@ -0,0 +1,75 @@ +repository->getActiveCategories(); + $parentCategories = $allCategories->whereNull('parent_id')->values(); + + // 현재 선택된 카테고리 객체 찾기 + $currentCategory = is_numeric($categoryOrSlug) + ? $allCategories->where('id', $categoryOrSlug)->first() + : $allCategories->where('slug', $categoryOrSlug)->first(); + + $menuItems = $parentCategories->map(function($parent) use ($allCategories, $currentCategory) { + $children = $allCategories->where('parent_id', $parent->id)->values(); + + return [ + 'id' => $parent->id, + 'slug' => $parent->slug, // 슬러그 추가 + 'label' => $parent->name, + 'url' => route('web.product.index', ['category' => $parent->slug]), // ID 대신 Slug로 URL 생성 + 'children' => $children->map(function($child) { + return [ + 'id' => $child->id, + 'slug' => $child->slug, + 'label' => $child->name, + 'url' => route('web.product.index', ['category' => $child->slug]), + ]; + }) + ]; + }); + + $products = $this->repository->getActiveProducts($categoryOrSlug, $search); + + return [ + 'menuItems' => $menuItems, + 'products' => $products, + 'currentCategory' => $currentCategory, + 'currentCategoryName' => $search ? "'{$search}' 검색 결과" : ($currentCategory ? $currentCategory->name : '전체 상품') + ]; + } + + /** + * 상품 상세 페이지 데이터 구성 + */ + public function getProductDetailData(int $id) + { + $product = $this->repository->getProductById($id); + if (!$product) return null; + + $skus = $this->repository->getSkusByProductId($id); + + // 허용된 결제수단 조회 + $allowedPayments = json_decode($product->allowed_payments ?? '[]', true); + $payments = $this->repository->getPaymentMethodsByIds($allowedPayments); + + // 사이드바용 카테고리 데이터 (기존 메서드 활용) + $menuData = $this->getListSceneData(null); + + return [ + 'product' => $product, + 'skus' => $skus, + 'payments' => $payments, + 'menuItems' => $menuData['menuItems'], + ]; + } +} diff --git a/composer.json b/composer.json index adf0492..0bb791c 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "intervention/image": "^3.11", "laravel/fortify": "^1.34", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", diff --git a/composer.lock b/composer.lock index 213697c..2545c91 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5489ef2cd1c47137632ceb06cdbab30b", + "content-hash": "0806303423926715aa8f817719c01f46", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1218,6 +1218,150 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "intervention/gif", + "version": "4.2.4", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Native PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/4.2.4" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-01-04T09:27:23+00:00" + }, + { + "name": "intervention/image", + "version": "3.11.6", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/5f6d27d9fd56312c47f347929e7ac15345c605a1", + "reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^4.2", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io" + } + ], + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/3.11.6" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2025-12-17T13:38:29+00:00" + }, { "name": "laravel/fortify", "version": "v1.34.1", diff --git a/config/web.php b/config/web.php index bdb4ebd..3b67e7b 100644 --- a/config/web.php +++ b/config/web.php @@ -7,7 +7,7 @@ return [ 'title' => '안전하고 빠른 상품권 거래', 'desc' => '가장 저렴한 온라인 상품권 판매. 구글플레이·문화상품권·편의점 등 인기 상품을 할인 구매하세요.', 'cta_label' => '상품 보러가기', - 'cta_url' => '/shop', + 'cta_url' => '/product/list', 'image' => [ 'default' => '/assets/images/common/hero/hero-01-shop.webp', 'mobile' => '/assets/images/common/hero/hero-01-shop.webp', @@ -19,7 +19,7 @@ return [ 'title' => '카드/휴대폰 결제 지원', 'desc' => '원하는 결제수단으로 편하게 결제하고, 발송은 빠르게 받아보세요.', 'cta_label' => '상품 보러가기', - 'cta_url' => '/shop', + 'cta_url' => '/product/list', 'image' => [ 'default' => '/assets/images/common/hero/hero-02-pay.webp', 'mobile' => '/assets/images/common/hero/hero-02-pay.webp', diff --git a/docs/product_table/pfy_phase1_schema.md b/docs/product_table/pfy_phase1_schema.md index 30f847c..4a4f469 100644 --- a/docs/product_table/pfy_phase1_schema.md +++ b/docs/product_table/pfy_phase1_schema.md @@ -124,26 +124,38 @@ CREATE TABLE IF NOT EXISTS pfy_product_contents ( - 재고는 `stock_mode` 로 (연동판매는 `infinite`, 자사핀은 `limited`) ```sql -CREATE TABLE IF NOT EXISTS pfy_product_skus ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK', - product_id BIGINT UNSIGNED NOT NULL COMMENT '상품 ID(pfy_products.id)', - denomination INT UNSIGNED NOT NULL COMMENT '권종 금액(예: 10000, 50000)', - normal_price INT UNSIGNED NOT NULL COMMENT '정상가(원)', - discount_rate DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '할인율(%)', - sale_price INT UNSIGNED NOT NULL COMMENT '판매가(원) - 운영/정산 안정 위해 계산 후 저장 권장', - stock_mode ENUM('infinite','limited') NOT NULL DEFAULT 'infinite' COMMENT '재고방식(무한/한정)', - is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '사용여부(1=사용,0=중지)', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일', - PRIMARY KEY (id), - KEY idx_pfy_skus_product (product_id), - KEY idx_pfy_skus_active (product_id, is_active), - KEY idx_pfy_skus_denomination (product_id, denomination), - CONSTRAINT fk_pfy_skus_product - FOREIGN KEY (product_id) REFERENCES pfy_products(id) - ON UPDATE CASCADE ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - COMMENT='[PFY] 상품 SKU(권종/가격 단위)'; +CREATE TABLE `pfy_product_skus` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'PK', + `product_id` bigint(20) unsigned NOT NULL COMMENT '상품 ID (pfy_products.id)', + + `sku_code` varchar(60) DEFAULT NULL COMMENT '내부 SKU 코드(선택). 운영상 필요 시 사용', + + `face_value` int(10) unsigned NOT NULL COMMENT '권면가(원) 예: 10000', + `normal_price` int(10) unsigned NOT NULL COMMENT '정상가(원)', + `discount_rate` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '할인율(%) 예: 3.50', + `sale_price` int(10) unsigned NOT NULL COMMENT '판매가(원) = floor(normal_price*(100-discount_rate)/100)', + + `status` enum('active','hidden') NOT NULL DEFAULT 'active' COMMENT '노출상태(active=노출, hidden=숨김)', + `sort` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '정렬(작을수록 우선)', + + `created_admin_id` bigint(20) unsigned DEFAULT NULL COMMENT '등록 관리자 ID', + `updated_admin_id` bigint(20) unsigned DEFAULT NULL COMMENT '수정 관리자 ID', + + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시', + + PRIMARY KEY (`id`), + + UNIQUE KEY `uniq_product_face` (`product_id`, `face_value`), + KEY `idx_product` (`product_id`), + KEY `idx_status_sort` (`status`, `sort`, `id`) + + /* FK를 쓰는 운영정책이면 아래 주석 해제 (기존 DB가 FK 거의 없으면 주석 유지 추천) + , CONSTRAINT `fk_pfy_skus_product` + FOREIGN KEY (`product_id`) REFERENCES `pfy_products` (`id`) + ON DELETE RESTRICT ON UPDATE CASCADE + */ +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PFY 상품 SKU(금액권/가격)'; ``` --- diff --git a/resources/views/admin/log/MemberAccountLogController.blade.php b/resources/views/admin/log/MemberAccountLogController.blade.php index 59c7d3b..6df80d5 100644 --- a/resources/views/admin/log/MemberAccountLogController.blade.php +++ b/resources/views/admin/log/MemberAccountLogController.blade.php @@ -18,6 +18,8 @@ .lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;} .lbtn--ghost{background:transparent;} .lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;} + .lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;} + .lbtn--primary:hover{background:rgba(59,130,246,.98);} .mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10); font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;} diff --git a/resources/views/admin/log/MemberLoginLogController.blade.php b/resources/views/admin/log/MemberLoginLogController.blade.php index e60345d..bf7c3c5 100644 --- a/resources/views/admin/log/MemberLoginLogController.blade.php +++ b/resources/views/admin/log/MemberLoginLogController.blade.php @@ -18,6 +18,8 @@ border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;} .lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;} .lbtn--ghost{background:transparent;} + .lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;} + .lbtn--primary:hover{background:rgba(59,130,246,.98);} .mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10); font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;} diff --git a/resources/views/admin/log/MemberPasswdModifyLogController.blade.php b/resources/views/admin/log/MemberPasswdModifyLogController.blade.php index d52c9f4..dee8661 100644 --- a/resources/views/admin/log/MemberPasswdModifyLogController.blade.php +++ b/resources/views/admin/log/MemberPasswdModifyLogController.blade.php @@ -18,6 +18,8 @@ border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;} .lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;} .lbtn--ghost{background:transparent;} + .lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;} + .lbtn--primary:hover{background:rgba(59,130,246,.98);} .mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10); font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;} diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index f96a742..258b609 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -48,31 +48,11 @@ [ 'title' => '상품 관리', 'items' => [ - // 1) 기본 데이터(전시 구조) - ['label' => '카테고리 관리', 'route' => 'admin.categories.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규) - - // 2) 상품 등록/관리 - ['label' => '상품 리스트', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']], - ['label' => '상품 등록', 'route' => 'admin.products.create', 'roles' => ['super_admin','product']], - - // 3) SKU/가격/권종 (상품 상세에서 같이 관리해도 되지만, 별도 메뉴가 있으면 운영 편함) - ['label' => '금액권/가격 관리', 'route' => 'admin.skus.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규) - - // 4) 판매채널/연동코드 (DANAL/KORCULTURE/KPREPAID 코드 매핑) - ['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index', 'roles' => ['super_admin','product']], - - // 5) 자산(이미지) 관리 - ['label' => '이미지 라이브러리', 'route' => 'admin.media.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규) - - // 6) 자사 핀 재고/회수/추출 - ['label' => '핀 번호 관리', 'route' => 'admin.pins.index', 'roles' => ['super_admin','product']], - // ['label' => '핀 회수/추출', 'route' => 'admin.pins.recalls', 'roles' => ['super_admin','product']], // ✅ 핀 메뉴 내부 탭으로 처리해도 OK - - // 7) 메인 노출/전시 - ['label' => '메인 노출 관리', 'route' => 'admin.exposure.index', 'roles' => ['super_admin','product']], - - // 8) 결제 정책(상품쪽에서 설정하지만 성격상 “정책”이므로 아래쪽) - ['label' => '결제 수수료/정책', 'route' => 'admin.fees.index', 'roles' => ['super_admin','product']], + ['label' => '카테고리 관리', 'route' => 'admin.categories.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' => '상품 이미지 라이브러리 관리', 'route' => 'admin.media.index', 'roles' => ['super_admin','product']], + ['label' => '판매 상품등록', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']], ], ], [ diff --git a/resources/views/admin/product/category/index.blade.php b/resources/views/admin/product/category/index.blade.php new file mode 100644 index 0000000..a0e8197 --- /dev/null +++ b/resources/views/admin/product/category/index.blade.php @@ -0,0 +1,239 @@ +@extends('admin.layouts.app') + +@section('title', '카테고리 관리') +@section('page_title', '카테고리 관리') +@section('page_desc', '상품 1차/2차 카테고리를 설정합니다.') + +@push('head') + +@endpush + +@section('content') +
+
+
카테고리 목록
+ +
+ @forelse($tree as $c1) +
+
+
+ + {{ $c1['is_active'] ? 'ON' : 'OFF' }} + + {{ $c1['name'] }} + ({{ $c1['slug'] }}) + @if(!empty($c1['search_keywords'])) +
+ @foreach(explode(' ', str_replace(',', ' ', $c1['search_keywords'])) as $tag) + @if(trim($tag)) + + {{ str_starts_with($tag, '#') ? $tag : '#'.$tag }} + + @endif + @endforeach +
+ @endif +
+ +
+ + + + +
+ @csrf @method('DELETE') + +
+
+
+ +
+ @foreach($c1['children'] as $c2) +
+
+ └ + + {{ $c2['is_active'] ? 'ON' : 'OFF' }} + + {{ $c2['name'] }} + ({{ $c2['slug'] }}) + + {{-- ✅ 추가: 등록된 해시태그 노출 --}} + @if(!empty($c2['search_keywords'])) +
+ @foreach(explode(' ', str_replace(',', ' ', $c2['search_keywords'])) as $tag) + @if(trim($tag)) + + {{ str_starts_with($tag, '#') ? $tag : '#'.$tag }} + + @endif + @endforeach +
+ @endif +
+ +
+ + + + +
+ @csrf @method('DELETE') + +
+
+
+ @endforeach +
+
+ @empty +
등록된 카테고리가 없습니다.
+ @endforelse +
+
+ +
+
새 카테고리 등록
+ +
+ @csrf + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
#을 붙여서 입력해주세요. 사용자 검색 시 활용됩니다.
+
+ +
+ + +
+ +
+ + +
+
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/product/fee/index.blade.php b/resources/views/admin/product/fee/index.blade.php new file mode 100644 index 0000000..2afc64e --- /dev/null +++ b/resources/views/admin/product/fee/index.blade.php @@ -0,0 +1,187 @@ +@extends('admin.layouts.app') + +@section('title', '결제/수수료 정책 관리') +@section('page_title', '결제/수수료 정책 관리') +@section('page_desc', '매입(출금) 기본 정책과 결제 수단별 PG 수수료를 관리합니다.') + +@push('head') + +@endpush + +@section('content') +
+
💸 매입(출금) 기본 수수료 정책
+
+ @csrf @method('PUT') +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+
+
💳 결제 수단 및 수수료 목록
+ +
+ @forelse($paymentMethods as $pm) +
+
+ + {{ $pm['is_active'] ? 'ON' : 'OFF' }} + + {{ $pm['name'] }} + 노출명칭 : {{$pm['display_name'] }} + CODE : {{ $pm['code'] }} + 고객: {{ (float)$pm['customer_fee_rate'] }}% + PG원가: {{ (float)$pm['pg_fee_rate'] }}% + + + +
+ +
+ + + + +
+ @csrf @method('DELETE') + +
+
+
+ @empty +
등록된 결제 수단이 없습니다.
+ @endforelse +
+
+ +
+
새 결제 수단 등록
+ +
+ @csrf + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/product/media/index.blade.php b/resources/views/admin/product/media/index.blade.php new file mode 100644 index 0000000..60c9ec5 --- /dev/null +++ b/resources/views/admin/product/media/index.blade.php @@ -0,0 +1,191 @@ +@extends('admin.layouts.app') + +@section('title', '이미지 라이브러리') +@section('page_title', '이미지 라이브러리') +@section('page_desc', '상품 등록 시 사용할 이미지를 폴더별로 관리하고 이름을 지정합니다.') + +@push('head') + +@endpush + +@section('content') +
+
📤 새 이미지 업로드
+
+ @csrf +
+
+ + +
※ 상품 종류별로 폴더를 나누면 관리가 편합니다.
+
+ +
+ + +
※ 입력 시 다중파일은 뒤에 _1, _2가 붙습니다. (비워두면 원본파일명)
+
+ +
+ + +
※ 최대 20장 동시 업로드 가능 (장당 2MB 제한)
+
+ +
+ +
+
+
+
+ +
+
+
🖼️ 라이브러리 목록
+ +
+ + @if($currentFolder) + 초기화 + @endif +
+
+ +
+ @forelse($page as $media) +
+
+ {{ $media->name ?? $media->original_name }} +
+
+
+
+ {{ $media->name ?? $media->original_name }} +
+ +
+ {{ $media->folder_name }} + {{ number_format($media->file_size / 1024, 1) }} KB +
+
+ {{ $media->original_name }} +
+
+ +
+ + + + +
+ @csrf @method('DELETE') + +
+
+
+
+ @empty +
+
등록된 이미지가 없습니다.
+
상단 업로드 영역에서 이미지를 등록해주세요.
+
+ @endforelse +
+ +
+ {{ $page->onEachSide(1)->links('vendor.pagination.admin') }} +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/product/pins/index.blade.php b/resources/views/admin/product/pins/index.blade.php new file mode 100644 index 0000000..a0499ea --- /dev/null +++ b/resources/views/admin/product/pins/index.blade.php @@ -0,0 +1,203 @@ +@extends('admin.layouts.app') + +@section('title', '핀(PIN) 재고 관리') +@section('page_title', '핀(PIN) 재고 관리') + +@push('head') + +@endpush + +@section('content') + +
+
+ 상품명: {{ $product->name }} + 권종명: {{ $sku->name }} +
+ ← 상품 수정으로 돌아가기 +
+ +
+
+
총 누적 핀
+
{{ number_format($stats['total']) }}
+
+
+
판매 대기 (AVAILABLE)
+
{{ number_format($stats['AVAILABLE']) }}
+
+
+
판매 완료 (SOLD)
+
{{ number_format($stats['SOLD']) }}
+
+
+
회수됨 (RECALLED)
+
{{ number_format($stats['RECALLED']) }}
+
+
+
기타 (결제중/만료)
+
{{ number_format($stats['HOLD'] + $stats['EXPIRED']) }}
+
+
+ +
+ +
+

➕ 핀 대량 등록 (붙여넣기)

+
+ @csrf +
+ +
+
+
+ + +
+ +
+
+
+ +
+

📤 최신 핀 회수 및 추출 (ZIP)

+
※ 등록된 역순(가장 최신 핀)으로 '판매 대기'인 핀만 회수합니다.
+ +
+ @csrf +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+
+
+ +
+
+

📋 핀 재고 목록 검색

+
+ +
+
+
+
+ + +
+
+ +
+ + +
+
+
+ @if(request('q') || request('status')) + 초기화 + @endif +
+
+
+ +
+ + + + + + + + + + + + + + @forelse($pins as $pin) + + + + + + + + + + @empty + + @endforelse + +
ID핀(PIN) 번호 (마스킹)정액 금액매입 원가마진율상태등록일시
{{ $pin->id }}{{ $pin->decrypted_code }}{{ number_format($pin->face_value) }}원{{ number_format($pin->buy_price) }}원{{ $pin->margin_rate }}% + @if($pin->status === 'AVAILABLE') 판매대기 + @elseif($pin->status === 'SOLD') 판매완료 + @elseif($pin->status === 'RECALLED') 회수됨 + @else {{ $pin->status }} @endif + {{ \Carbon\Carbon::parse($pin->created_at)->format('Y-m-d H:i') }}
등록된 핀 번호가 없습니다.
+
+
+ {{ $pins->onEachSide(1)->links('vendor.pagination.admin') }} +
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/product/products/create.blade.php b/resources/views/admin/product/products/create.blade.php new file mode 100644 index 0000000..c2b4bb2 --- /dev/null +++ b/resources/views/admin/product/products/create.blade.php @@ -0,0 +1,560 @@ +@extends('admin.layouts.app') + +@section('title', '상품 등록') +@section('page_title', '상품 및 권종 등록') + +@push('head') + + + + + +@endpush + +@section('content') + + @if ($errors->any()) +
+ ⚠️ 저장 실패 (입력값을 확인해주세요) + +
+ @endif + +
+ ← 목록으로 돌아가기 +
+ +
+ @csrf + +
+
📦 1. 상품 기본 정보
+
+
+ + +
+
+
이미지 선택
+ preview +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ @foreach($payments as $pm) + + @endforeach +
+
+
+ +
+ +
+ + ~ + +
+
+
+
+
+
+ +
+
🛒 2. 장바구니 및 구매 정책
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
🏷️ 3. 권종(옵션) 및 연동 설정
+
+ +
+
+
+
+ +
+
📝 4. 상세 설명 및 안내
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+ + + + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/product/products/edit.blade.php b/resources/views/admin/product/products/edit.blade.php new file mode 100644 index 0000000..e77563a --- /dev/null +++ b/resources/views/admin/product/products/edit.blade.php @@ -0,0 +1,605 @@ +@extends('admin.layouts.app') + +@section('title', '상품 수정') +@section('page_title', '상품 및 권종 수정') + +@push('head') + + + + + +@endpush + +@section('content') + + @if ($errors->any()) +
+ ⚠️ 수정 실패 (입력값을 확인해주세요) + +
+ @endif + +
+ ← 목록으로 돌아가기 +
+ +
+ @csrf + @method('PUT') +
+
📦 1. 상품 기본 정보
+
+
+ + +
+
+
이미지 선택
+ preview +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + @php + // JSON 결제 수단 배열화 + $savedPayments = old('payment_methods', $product->allowed_payments ?? []); + @endphp +
+ +
+ @foreach($payments as $pm) + + @endforeach +
+
+
+ +
+ +
+ + ~ + +
+
+
+
+
+
+ +
+
🛒 2. 장바구니 및 구매 정책
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
🏷️ 3. 권종(옵션) 및 연동 설정
+
+ +
+
+
+
+ +
+
📝 4. 상세 설명 및 안내
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+ + + + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/product/products/index.blade.php b/resources/views/admin/product/products/index.blade.php new file mode 100644 index 0000000..41e9183 --- /dev/null +++ b/resources/views/admin/product/products/index.blade.php @@ -0,0 +1,158 @@ +@extends('admin.layouts.app') + +@section('title', '상품 관리') +@section('page_title', '상품 목록') +@section('page_desc', '등록된 상품(권종 포함)을 조회하고 관리합니다.') + +@push('head') + +@endpush + +@section('content') + + +
+
+
{{ number_format($products->total()) }}개의 상품
+ + 새 상품 등록 +
+ +
+ + + + + + + + + + + + + + @forelse($products as $p) + + + + + + + + + + @empty + + @endforelse + +
ID이미지카테고리 / 상품명권종 상세 (API / 재고)판매 기간 / 매입상태관리
{{ $p->id }} +
+ @if($p->thumbnail_path) + thumb + @else + NO
IMAGE
+ @endif +
+
+
{{ $p->category_path ?? '카테고리 없음' }}
+ {{ $p->name }} +
+ @if($p->purchase_type === 'MULTI_SKU') [복합구매] + @elseif($p->purchase_type === 'MULTI_QTY') [단일 다수] + @else [1장 한정] @endif +
+
+ @foreach($p->skus as $sku) +
+ {{ number_format($sku->face_value) }}원 + + @if($sku->discount_value > 0) + + (↓ {{ $sku->discount_type === 'PERCENT' ? $sku->discount_value.'%' : number_format($sku->discount_value).'원' }}) + + @endif + + | {{ $sku->tax_type === 'TAX' ? '과세' : '면세' }} | + + @if($sku->sales_method === 'API_LINK') + API: {{ $sku->provider_name ?? '연동사' }} {{ $sku->api_product_code ? '('.$sku->api_product_code.')' : '' }} + @else + 자사핀 재고: {{ number_format($sku->available_pins) }} / {{ number_format($sku->total_pins) }} + @endif +
+ @endforeach +
+ @if($p->is_always_on_sale) +
상시판매
+ @else +
{{ \Carbon\Carbon::parse($p->sales_end_at)->format('Y-m-d H시 마감') }}
+ @endif + + @if($p->is_buyback_allowed) + 매입가능 + @else + 매입불가 + @endif +
+ @if($p->status === 'ACTIVE') 판매중 + @elseif($p->status === 'SOLD_OUT') 품절 + @else 숨김 @endif + + 수정 +
등록된 상품이 없습니다.
+
+
+ {{ $products->onEachSide(1)->links('vendor.pagination.admin') }} +
+
+@endsection diff --git a/resources/views/admin/product/salecode/index.blade.php b/resources/views/admin/product/salecode/index.blade.php new file mode 100644 index 0000000..1f16458 --- /dev/null +++ b/resources/views/admin/product/salecode/index.blade.php @@ -0,0 +1,230 @@ +@extends('admin.layouts.app') + +@section('title', '판매 코드 관리') +@section('page_title', 'API 연동 판매 코드 관리') +@section('page_desc', '외부 연동사(다날 등)와 해당 업체의 고유 상품 코드를 매핑합니다.') + +@push('head') + +@endpush + +@section('content') +
+
+
🏢 연동사 및 📦 상품코드 목록
+ +
+ @forelse($tree as $pv) +
+
+
+ + {{ $pv['is_active'] ? 'ON' : 'OFF' }} + + {{ $pv['name'] }} + [{{ $pv['code'] }}] +
+
+ +
+ @csrf @method('DELETE') + +
+
+
+ +
+ @foreach($pv['children'] as $cd) +
+
+ └ + + {{ $cd['is_active'] ? 'ON' : 'OFF' }} + + {{ $cd['name'] }} + ({{ $cd['api_code'] }}) +
+
+ + + +
+ @csrf @method('DELETE') + +
+
+
+ @endforeach +
+
+ @empty +
등록된 연동사 및 코드가 없습니다.
+ @endforelse +
+
+ +
+
+
📦 상품 코드 등록
+ +
+ @csrf + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
🏢 신규 연동사 (Provider) 등록
+ +
+ @csrf + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/skus/create.blade.php b/resources/views/admin/skus/create.blade.php new file mode 100644 index 0000000..2e31926 --- /dev/null +++ b/resources/views/admin/skus/create.blade.php @@ -0,0 +1,168 @@ +@extends('admin.layouts.app') + +@section('title', '권종 등록') +@section('page_title', '권종 등록') +@section('page_desc', '상품별 권종/가격(SKU)을 등록합니다.') + +@push('head') + +@endpush + +@section('content') + @php + $qs = request()->only(['q','category_id','product_id','status','page']); + @endphp + +
+
+
+
SKU 등록
+
상품별 권종/가격(SKU)을 등록합니다.
+
+ + + ← 목록 + +
+
+ +
+ @csrf + +
+
+
+ + +
+ +
+ + +
※ 내부 관리용 식별자(없어도 됨)
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
-
+
※ 저장 시 서버에서 계산되어 sale_price에 저장됩니다.
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+ ※ 권면가/정상가/할인율 기반으로 판매가가 계산됩니다. +
+
+ 취소 + +
+
+
+
+ + +@endsection diff --git a/resources/views/admin/skus/edit.blade.php b/resources/views/admin/skus/edit.blade.php new file mode 100644 index 0000000..a14e4a8 --- /dev/null +++ b/resources/views/admin/skus/edit.blade.php @@ -0,0 +1,237 @@ +@extends('admin.layouts.app') + +@section('title', 'SKU 수정') +@section('page_title', 'SKU 수정') +@section('page_desc', 'SKU(권종/가격)를 수정합니다.') + +@push('head') + +@endpush + +@section('content') + @php + $p = $sku ?? null; + + $qs = request()->only(['q','category_id','product_id','status','page']); + + $st = (string)old('status', (string)($p->status ?? 'active')); + $pill = $st === 'active' ? 'pill--ok' : 'pill--bad'; + $stText = $st === 'active' ? '노출' : '숨김'; + + $face = (int)old('face_value', (int)($p->face_value ?? 0)); + $normal = (int)old('normal_price', (int)($p->normal_price ?? 0)); + $rate = (string)old('discount_rate', (string)($p->discount_rate ?? '0.00')); + $sale = (int)($p->sale_price ?? 0); + @endphp + +
+
+
+
SKU 수정
+
+ #{{ (int)($p->id ?? 0) }} +
+
+ + + ← 목록 + +
+
+ + {{-- 요약 --}} +
+
+
상태
+
● {{ $stText }}
+
+
+
현재 판매가
+
{{ number_format($sale) }} 원
+
+
+
재고방식
+
{{ (string)($p->stock_mode ?? '-') }}
+
+
+
생성
+
{{ $p->created_at ?? '-' }}
+
+
+
최근 수정
+
{{ $p->updated_at ?? '-' }}
+
+
+
상품 ID
+
{{ (int)($p->product_id ?? 0) }}
+
+
+ + {{-- 수정 폼 --}} +
+ @csrf + @method('PUT') + + {{-- ✅ 저장 후 목록 복귀 시 같은 필터/페이지 유지용 --}} + @foreach($qs as $k=>$v) + + @endforeach + +
+
+
+ +
+ 상품 변경은 운영상 위험해서(재고/정산/장부 연계) 기본은 고정입니다. + product_id={{ (int)($p->product_id ?? 0) }} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
-
+
※ 저장 시 서버에서 계산되어 sale_price에 저장됩니다.
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+ ※ 권면가/정상가/할인율 기반으로 판매가가 계산됩니다. +
+
+ + + 목록 + + +
+
+
+
+ + {{-- ✅ 삭제 폼 (중첩 form 금지) --}} +
+ @csrf + @method('DELETE') + + @foreach($qs as $k=>$v) + + @endforeach +
+ + +@endsection diff --git a/resources/views/admin/skus/index.blade.php b/resources/views/admin/skus/index.blade.php new file mode 100644 index 0000000..f42dd1b --- /dev/null +++ b/resources/views/admin/skus/index.blade.php @@ -0,0 +1,268 @@ +@extends('admin.layouts.app') + +@section('title', '권종 관리') +@section('page_title', '권종 관리') +@section('page_desc', '상품별 권종/가격(SKU)을 관리합니다.') +@section('content_class', 'a-content--full') + +@push('head') + +@endpush + +@section('content') + @php + $filters = $filters ?? []; + $catId = (string)($filters['category_id'] ?? ''); + $prdId = (string)($filters['product_id'] ?? ''); + $stSel = (string)($filters['status'] ?? ''); + $q = (string)($filters['q'] ?? ''); + + $statusPill = function(?string $st): string { + $st = (string)($st ?? ''); + return match($st) { + 'active' => 'pill--ok', + 'hidden' => 'pill--bad', + default => 'pill--muted', + }; + }; + + $statusLabel = function(?string $st): string { + $st = (string)($st ?? ''); + return match($st) { + 'active' => '노출', + 'hidden' => '숨김', + default => $st ?: '-', + }; + }; + + $stockLabel = function(?string $m): string { + $m = (string)($m ?? ''); + return match($m) { + 'infinite' => '무한', + 'limited' => '한정', + default => $m ?: '-', + }; + }; + @endphp + +
+
+
+
권종관리
+
상품별 권종/가격(SKU)을 관리합니다.
+
+ +
+ + 권종 등록 + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + 초기화 +
+
+
+
+
+ +
+
+ 총 {{ $page->total() }}개 +
+ +
+ + + + + + + + + + + + + + + + + + @forelse($page as $row) + @php + $id = (int)($row->id ?? 0); + $st = (string)($row->status ?? ''); + $pill = $statusPill($st); + $stText = $statusLabel($st); + + $pname = (string)($row->product_name ?? '-'); + $c1 = (string)($row->parent_category_name ?? ''); + $c2 = (string)($row->category_name ?? ''); + $catText = trim($c1) !== '' ? ($c1.' / '.$c2) : ($c2 ?: '-'); + + $face = (int)($row->face_value ?? 0); + $normal = (int)($row->normal_price ?? 0); + $rate = (string)($row->discount_rate ?? '0.00'); + $sale = (int)($row->sale_price ?? 0); + + $smode = $stockLabel($row->stock_mode ?? null); + $updated = $row->updated_at ?? '-'; + @endphp + + + + + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
ID상품권면가정상가할인율판매가재고방식상태수정일관리
{{ $id }} +
{{ $pname }}
+
{{ $catText }}
+ @if(!empty($row->sku_code)) +
{{ $row->sku_code }}
+ @endif +
{{ number_format($face) }}{{ number_format($normal) }} 원{{ $rate }}%{{ number_format($sale) }}{{ $smode }} + ● {{ $stText }} + {{ $updated }} + + 수정 + +
데이터가 없습니다.
+
+ +
+ {{ $page->onEachSide(1)->links('vendor.pagination.admin') }} +
+
+ + +@endsection diff --git a/resources/views/web/company/header.blade.php b/resources/views/web/company/header.blade.php index 808aa0a..fc5e7e2 100644 --- a/resources/views/web/company/header.blade.php +++ b/resources/views/web/company/header.blade.php @@ -19,7 +19,7 @@