집계 — 판매량 정렬과 인기 상품이 ORDER BY 안의 상관 서브쿼리라 LIMIT 이 평가 범위를 막지 못했다. 전 상품을 평가한 뒤 정렬하던 것을 파생 테이블 조인으로 바꿨다. 리뷰 개수·평점도 상품마다 리뷰 표를 두 번씩 훑던 것을 한 번의 그룹 집계로 합쳤다. 대표 이미지는 이미 적재된 관계를 쓰지 않고 매번 관계 빌더로 조회해 상품당 2회씩 쿼리를 더 내고 있었다. 루프 안 쿼리 — 체크아웃의 항목별 옵션 조회, 게스트 장바구니 병합의 항목별 2쿼리, 주문 옵션의 항목별 find 를 일괄 조회로 옮겼다. 상품 대량 가격·재고 수정은 행마다 저장을 반복했는데, 두 번째 인자를 넘기던 오용 때문에 실제로는 바인딩이 어긋나 실행 자체가 실패하고 있었다. 이 두 메서드를 호출하는 테스트가 0건이라 전 스위트 green 상태로 통과하고 있었다. 카테고리 트리는 노드마다 집계 쿼리를 재귀 발행하던 것을 평면 1쿼리 + 조립으로 바꾸고 캐시했다. 거의 모든 쇼핑 화면이 읽는 값이라 분류·상품 변경 시 무효화한다. 검색 — 조건에 맞는 ID 전량을 PHP 로 적재한 뒤 무제한 whereIn 으로 다시 조회하던 경로를 없애고 키워드 술어를 페이지 쿼리에 직접 붙였다. 구체 엔진 직접 호출도 해석기 경유로 옮겨 플러그인 검색엔진이 상품·쿠폰·브랜드 검색에 적용된다. 쇼핑 첫 화면과 분류 화면은 각각 5요청·3요청을 한 번으로 합쳤다. 분류 화면이 함께 받던 분류 트리는 그 화면의 어떤 표현식도 읽지 않는 순수 낭비였다. 없는 분류 주소는 빈 화면 대신 404 로 처리해 검색엔진이 정상 페이지로 수집하지 않게 했다. 색인 — 전시 조건으로 좁힌 뒤 등록일·판매가로 정렬하는 목록과 주문 옵션 판매량 집계에 필요한 색인을 더했다. 신규 설치는 마이그레이션이, 기존 사이트는 업그레이드 스텝이 적용한다. 관리자 화면 — 총 건수가 잘린 목록에서 페이저가 접히던 자리와, 쿠폰 사용조건의 상품 검색이 추가 로드되지 않던 조건을 고쳤다. 같은 파일의 사전 결함(반복 렌더 안의 정적 id, Icon 박스 크기, 다크 모드 대비, 반복 변수 선언 오탈자)도 함께 처리했다.
1084 lines
43 KiB
PHP
1084 lines
43 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Services;
|
|
|
|
use App\Extension\HookManager;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
|
|
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
|
|
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
|
|
use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\CartEmptyException;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\CartOperationException;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\TempOrderNotFoundException;
|
|
use Modules\Sirsoft\Ecommerce\Models\Cart;
|
|
use Modules\Sirsoft\Ecommerce\Models\Product;
|
|
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
|
|
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CartRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\TempOrderRepositoryInterface;
|
|
|
|
/**
|
|
* 임시 주문 서비스
|
|
*
|
|
* 주문서 작성 단계에서 장바구니 아이템을 임시 주문으로 변환하고
|
|
* 주문 계산 결과를 저장/관리하는 서비스입니다.
|
|
*/
|
|
class TempOrderService
|
|
{
|
|
/**
|
|
* 임시 주문 만료 시간 (분)
|
|
*/
|
|
protected const EXPIRATION_MINUTES = 30;
|
|
|
|
public function __construct(
|
|
protected TempOrderRepositoryInterface $tempOrderRepository,
|
|
protected CartRepositoryInterface $cartRepository,
|
|
protected CouponIssueRepositoryInterface $couponIssueRepository,
|
|
protected ProductOptionRepositoryInterface $productOptionRepository,
|
|
protected OrderCalculationService $orderCalculationService,
|
|
protected PurchaseEligibilityService $purchaseEligibilityService,
|
|
protected UserMileageService $userMileageService,
|
|
protected AdditionalOptionSelectionService $additionalOptionSelectionService
|
|
) {}
|
|
|
|
/**
|
|
* 임시 주문 생성 (주문하기 클릭 시)
|
|
*
|
|
* 장바구니 아이템을 받아 주문 계산 후 임시 주문을 생성합니다.
|
|
* 동일 cart_key 또는 user_id가 존재하면 덮어씁니다.
|
|
* 쿠폰은 초기 생성 시 적용하지 않고, updateTempOrder에서 적용합니다.
|
|
*
|
|
* @param Collection $cartItems 장바구니 아이템 컬렉션
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @param int $usePoints 사용할 마일리지
|
|
* @return TempOrder 생성된 임시 주문
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public function createTempOrder(
|
|
Collection $cartItems,
|
|
?int $userId,
|
|
?string $cartKey,
|
|
int $usePoints = 0
|
|
): TempOrder {
|
|
if ($cartItems->isEmpty()) {
|
|
throw new CartEmptyException(__('sirsoft-ecommerce::exceptions.cart_empty'));
|
|
}
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.before_create', $cartItems, $userId, $cartKey);
|
|
|
|
// 마일리지 사용 검증
|
|
$validatedPoints = $this->validatePointsUsage($userId, $usePoints);
|
|
|
|
// 장바구니 아이템을 CalculationItem으로 변환
|
|
$calculationItems = $this->convertCartItemsToCalculationItems($cartItems);
|
|
|
|
// 주문 계산 실행 (쿠폰 없이)
|
|
$calculationInput = new CalculationInput(
|
|
items: $calculationItems,
|
|
usePoints: $validatedPoints,
|
|
userId: $userId,
|
|
);
|
|
|
|
$calculationResult = $this->orderCalculationService->calculate($calculationInput);
|
|
|
|
// 마일리지 사용 정책(최소사용액/사용단위/최대한도) 최종 검증.
|
|
// 결제금액이 확정된 뒤라야 정률 한도를 판정할 수 있으므로 계산 직후에 둔다.
|
|
$this->assertPointsWithinUsagePolicy($userId, $validatedPoints, $calculationResult);
|
|
|
|
// 임시 주문 생성/수정
|
|
$tempOrder = DB::transaction(function () use (
|
|
$cartItems,
|
|
$userId,
|
|
$cartKey,
|
|
$validatedPoints,
|
|
$calculationResult
|
|
) {
|
|
$data = [
|
|
'cart_key' => $cartKey,
|
|
'user_id' => $userId,
|
|
'items' => $this->serializeCartItems($cartItems),
|
|
'calculation_input' => [
|
|
'promotions' => [
|
|
'item_coupons' => [],
|
|
'order_coupon_issue_id' => null,
|
|
'shipping_coupon_issue_id' => null,
|
|
],
|
|
'use_points' => $validatedPoints,
|
|
'shipping_address' => null,
|
|
],
|
|
'calculation_result' => $calculationResult->toArray(),
|
|
'expires_at' => Carbon::now()->addMinutes(self::EXPIRATION_MINUTES),
|
|
];
|
|
|
|
return $this->tempOrderRepository->upsert($data);
|
|
});
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.after_create', $tempOrder);
|
|
|
|
return $tempOrder;
|
|
}
|
|
|
|
/**
|
|
* 선택된 장바구니 아이템으로 임시 주문 생성
|
|
*
|
|
* 장바구니 ID 배열을 받아 해당 아이템들만 임시 주문에 포함합니다.
|
|
* 쿠폰은 초기 생성 시 적용하지 않고, updateTempOrder에서 적용합니다.
|
|
*
|
|
* @param array $cartIds 선택된 장바구니 ID 배열
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @param int $usePoints 사용할 마일리지
|
|
* @return TempOrder 생성된 임시 주문
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public function createTempOrderFromSelectedItems(
|
|
array $cartIds,
|
|
?int $userId,
|
|
?string $cartKey,
|
|
int $usePoints = 0
|
|
): TempOrder {
|
|
// 선택된 장바구니 아이템 조회
|
|
$cartItems = $this->cartRepository->findByIds($cartIds);
|
|
|
|
// 권한 확인: 본인 장바구니만 허용
|
|
$authorizedItems = $cartItems->filter(function ($item) use ($userId, $cartKey) {
|
|
if ($userId !== null) {
|
|
return $item->user_id === $userId;
|
|
}
|
|
|
|
return $item->cart_key === $cartKey && $item->user_id === null;
|
|
});
|
|
|
|
// 재고/판매상태/구매대상제한 검증 (실패 시 CartUnavailableException 발생)
|
|
$userRoleIds = $this->purchaseEligibilityService->resolveRoleIds(Auth::user());
|
|
$this->validateCartItemsAvailability($authorizedItems, $userRoleIds);
|
|
|
|
return $this->createTempOrder($authorizedItems, $userId, $cartKey, $usePoints);
|
|
}
|
|
|
|
/**
|
|
* 바로 구매: 장바구니를 경유하지 않고 직접 항목으로 임시 주문 생성
|
|
*
|
|
* 장바구니에 행을 만들지 않고(오염 방지) 선택한 상품/옵션/수량으로 바로 임시 주문을
|
|
* 생성합니다. 판매상태·재고·구매수량 한도·구매대상제한 검증은 장바구니 담기와 동일하게
|
|
* 적용하되, 구매수량 한도는 장바구니 기존 수량과 합산하지 않고 이번 선택 수량만으로 판정합니다.
|
|
*
|
|
* @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}]
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @param int $usePoints 사용할 마일리지
|
|
* @return TempOrder 생성된 임시 주문
|
|
*
|
|
* @throws \Exception 상품/옵션 미존재 또는 구매 불가 시
|
|
*/
|
|
public function createTempOrderFromDirectItems(
|
|
array $items,
|
|
?int $userId,
|
|
?string $cartKey,
|
|
int $usePoints = 0
|
|
): TempOrder {
|
|
// 직접 항목을 미저장 Cart 모델로 변환 (장바구니 행을 만들지 않음)
|
|
$cartItems = $this->buildTransientCartItems($items, $userId, $cartKey);
|
|
|
|
// 재고/판매상태/구매대상제한/구매수량 한도 검증 (담기와 동일 — 단, 합산은 이번 선택 수량만)
|
|
$userRoleIds = $this->purchaseEligibilityService->resolveRoleIds(Auth::user());
|
|
$this->validateCartItemsAvailability($cartItems, $userRoleIds);
|
|
|
|
return $this->createTempOrder($cartItems, $userId, $cartKey, $usePoints);
|
|
}
|
|
|
|
/**
|
|
* 직접 항목 배열을 미저장 Cart 모델 컬렉션으로 변환합니다.
|
|
*
|
|
* product_option_id 로 옵션을 매칭하고(없으면 기본 옵션), product/productOption 관계를
|
|
* set 한 미저장 Cart 모델을 만들어 기존 검증/계산/직렬화 로직을 그대로 재사용합니다.
|
|
*
|
|
* @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}]
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @return Collection<int, Cart> 미저장 Cart 컬렉션
|
|
*
|
|
* @throws \Exception 상품/옵션 미존재 시
|
|
*/
|
|
protected function buildTransientCartItems(array $items, ?int $userId, ?string $cartKey): Collection
|
|
{
|
|
$cartItems = new Collection;
|
|
|
|
// 항목마다 옵션을 두 번 조회하면 체크아웃 한 번에 항목 수 × 2 만큼 쿼리가 난다.
|
|
// 상품별 옵션과 확정된 옵션 상세를 각각 한 번씩만 읽어 맵으로 들고 간다.
|
|
$optionsByProduct = $this->productOptionRepository->getByProductIds(
|
|
array_map(fn ($item) => (int) ($item['product_id'] ?? 0), $items)
|
|
);
|
|
|
|
$resolvedOptionIds = [];
|
|
|
|
foreach ($items as $index => $item) {
|
|
$productId = (int) ($item['product_id'] ?? 0);
|
|
$requestedOptionId = $item['product_option_id'] ?? null;
|
|
$productOptions = $optionsByProduct->get($productId) ?? new Collection;
|
|
|
|
if (! empty($requestedOptionId)) {
|
|
$matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId);
|
|
|
|
if (! $matchedOption) {
|
|
throw new CartOperationException('option_not_found');
|
|
}
|
|
|
|
$resolvedOptionIds[$index] = $matchedOption->id;
|
|
|
|
continue;
|
|
}
|
|
|
|
$defaultOption = $productOptions->first();
|
|
if (! $defaultOption) {
|
|
throw new CartOperationException('option_not_found');
|
|
}
|
|
|
|
$resolvedOptionIds[$index] = $defaultOption->id;
|
|
}
|
|
|
|
// product/images 관계를 포함해 확정 옵션을 한 번에 재조회 (검증·계산에 필요)
|
|
$optionsById = $this->productOptionRepository
|
|
->findByIdsWithProduct(array_values($resolvedOptionIds))
|
|
->keyBy('id');
|
|
|
|
// 추가옵션 검증 자료도 항목 루프 전에 한 번에 적재한다. 이걸 빼면 아래 루프가
|
|
// 항목마다 선택지·상품·그룹을 다시 읽는다(항목당 3쿼리).
|
|
$this->additionalOptionSelectionService->prefetchForProducts(
|
|
$optionsById->pluck('product_id')->all()
|
|
);
|
|
|
|
foreach ($items as $index => $item) {
|
|
$quantity = max(1, (int) ($item['quantity'] ?? 1));
|
|
|
|
$option = $optionsById->get($resolvedOptionIds[$index]);
|
|
if (! $option || ! $option->product) {
|
|
throw new CartOperationException('option_not_found');
|
|
}
|
|
|
|
// 추가옵션 선택 검증·정규화 (담기와 동일 서버 SSoT — D9/D12)
|
|
$additionalSelections = $this->additionalOptionSelectionService->validateAndNormalize(
|
|
$option->product_id,
|
|
$item['additional_option_selections'] ?? null
|
|
);
|
|
|
|
// 미저장 Cart 모델 구성 (id 없음 — 장바구니 행 미생성)
|
|
$cart = new Cart([
|
|
'cart_key' => $cartKey,
|
|
'user_id' => $userId,
|
|
'product_id' => $option->product_id,
|
|
'product_option_id' => $option->id,
|
|
'additional_option_selections' => ! empty($additionalSelections) ? $additionalSelections : null,
|
|
'quantity' => $quantity,
|
|
]);
|
|
$cart->setRelation('product', $option->product);
|
|
$cart->setRelation('productOption', $option);
|
|
|
|
$cartItems->push($cart);
|
|
}
|
|
|
|
return $cartItems;
|
|
}
|
|
|
|
/**
|
|
* 유효한 임시 주문 조회
|
|
*
|
|
* 만료되지 않은 임시 주문을 조회합니다.
|
|
*
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @return TempOrder|null 유효한 임시 주문 (없거나 만료되면 null)
|
|
*/
|
|
public function getTempOrder(?int $userId, ?string $cartKey): ?TempOrder
|
|
{
|
|
return $this->tempOrderRepository->findValidByUserOrCartKey($userId, $cartKey);
|
|
}
|
|
|
|
/**
|
|
* 임시 주문 업데이트 (쿠폰/마일리지/배송주소 변경 시 재계산)
|
|
*
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @param array $promotions 프로모션 정보 {item_coupons, order_coupon_issue_id, shipping_coupon_issue_id}
|
|
* @param int $usePoints 사용할 마일리지
|
|
* @param ShippingAddress|null $shippingAddress 배송 주소 정보
|
|
* @return TempOrder|null 수정된 임시 주문
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public function updateTempOrder(
|
|
?int $userId,
|
|
?string $cartKey,
|
|
array $promotions = [],
|
|
?int $usePoints = 0,
|
|
?ShippingAddress $shippingAddress = null
|
|
): ?TempOrder {
|
|
$tempOrder = $this->getTempOrder($userId, $cartKey);
|
|
|
|
if (! $tempOrder) {
|
|
throw new TempOrderNotFoundException(__('sirsoft-ecommerce::exceptions.temp_order_not_found'));
|
|
}
|
|
|
|
// 미전송 필드는 기존 값 유지 (배송 주소만 변경 시 쿠폰/마일리지 초기화 방지)
|
|
$existingPromotions = $tempOrder->getPromotions() ?? [];
|
|
|
|
// 마일리지: null이면 기존 값 유지
|
|
if ($usePoints === null) {
|
|
$usePoints = $tempOrder->getUsedPoints() ?? 0;
|
|
}
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.before_update', $tempOrder, $promotions, $usePoints, $shippingAddress);
|
|
|
|
// 프로모션에서 쿠폰 정보 추출 (미전송 키는 기존 값으로 폴백)
|
|
$itemCoupons = array_key_exists('item_coupons', $promotions)
|
|
? ($promotions['item_coupons'] ?? [])
|
|
: ($existingPromotions['item_coupons'] ?? []);
|
|
$orderCouponIssueId = array_key_exists('order_coupon_issue_id', $promotions)
|
|
? $promotions['order_coupon_issue_id']
|
|
: ($existingPromotions['order_coupon_issue_id'] ?? null);
|
|
$shippingCouponIssueId = array_key_exists('shipping_coupon_issue_id', $promotions)
|
|
? $promotions['shipping_coupon_issue_id']
|
|
: ($existingPromotions['shipping_coupon_issue_id'] ?? null);
|
|
|
|
// 상품별 쿠폰 소유권 검증 및 필터링
|
|
$validatedItemCoupons = $this->validateAndFilterItemCoupons($userId, $itemCoupons);
|
|
|
|
// 주문/배송비 쿠폰 소유권 검증
|
|
$validatedOrderCouponId = $this->validateSingleCoupon($userId, $orderCouponIssueId);
|
|
$validatedShippingCouponId = $this->validateSingleCoupon($userId, $shippingCouponIssueId);
|
|
|
|
// 마일리지 사용 검증
|
|
$validatedPoints = $this->validatePointsUsage($userId, $usePoints);
|
|
|
|
// 기존 아이템 데이터로 재계산
|
|
$items = $tempOrder->items ?? [];
|
|
$calculationItems = array_map(
|
|
fn (array $item) => CalculationItem::fromArray($item),
|
|
$items
|
|
);
|
|
|
|
// 배송 주소가 없으면 기존 저장된 주소 사용
|
|
if ($shippingAddress === null && $tempOrder->getShippingAddress() !== null) {
|
|
$shippingAddress = ShippingAddress::fromArray($tempOrder->getShippingAddress());
|
|
}
|
|
|
|
// CalculationInput에 전달할 쿠폰 ID 배열 구성
|
|
$couponIssueIds = array_filter([$validatedOrderCouponId, $validatedShippingCouponId]);
|
|
|
|
$calculationInput = new CalculationInput(
|
|
items: $calculationItems,
|
|
couponIssueIds: $couponIssueIds,
|
|
itemCoupons: $validatedItemCoupons,
|
|
usePoints: $validatedPoints,
|
|
shippingAddress: $shippingAddress,
|
|
userId: $userId,
|
|
);
|
|
|
|
$calculationResult = $this->orderCalculationService->calculate($calculationInput);
|
|
|
|
// 마일리지 사용 정책(최소사용액/사용단위/최대한도) 최종 검증 (생성 경로와 동일 기준)
|
|
$this->assertPointsWithinUsagePolicy($userId, $validatedPoints, $calculationResult);
|
|
|
|
// 검증된 프로모션 정보 구성 (프론트 필드명 그대로 저장)
|
|
$validatedPromotions = [
|
|
'item_coupons' => $validatedItemCoupons,
|
|
'order_coupon_issue_id' => $validatedOrderCouponId,
|
|
'shipping_coupon_issue_id' => $validatedShippingCouponId,
|
|
];
|
|
|
|
$tempOrder = DB::transaction(function () use ($tempOrder, $validatedPromotions, $validatedPoints, $shippingAddress, $calculationResult) {
|
|
$updateData = [
|
|
'calculation_input' => [
|
|
'promotions' => $validatedPromotions,
|
|
'use_points' => $validatedPoints,
|
|
'shipping_address' => $shippingAddress?->toArray(),
|
|
],
|
|
'calculation_result' => $calculationResult->toArray(),
|
|
'expires_at' => Carbon::now()->addMinutes(self::EXPIRATION_MINUTES),
|
|
];
|
|
|
|
return $this->tempOrderRepository->update($tempOrder, $updateData);
|
|
});
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.after_update', $tempOrder);
|
|
|
|
return $tempOrder;
|
|
}
|
|
|
|
/**
|
|
* 임시 주문 조회 및 실시간 재계산
|
|
*
|
|
* 저장된 임시 주문의 아이템/쿠폰/마일리지/주소 정보로 실시간 재계산하여 반환합니다.
|
|
* 상품 가격 변경, 쿠폰 만료 등이 반영됩니다.
|
|
*
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @param ShippingAddress|null $shippingAddress 배송 주소 (null이면 저장된 주소 사용)
|
|
* @return array{temp_order: TempOrder, calculation: OrderCalculationResult}|null
|
|
*/
|
|
public function getTempOrderWithCalculation(
|
|
?int $userId,
|
|
?string $cartKey,
|
|
?ShippingAddress $shippingAddress = null
|
|
): ?array {
|
|
$tempOrder = $this->getTempOrder($userId, $cartKey);
|
|
|
|
if (! $tempOrder) {
|
|
return null;
|
|
}
|
|
|
|
// 조회 시 만료 시간 자동 연장 (사용자가 아직 결제 중임을 의미)
|
|
$tempOrder = $this->tempOrderRepository->update($tempOrder, [
|
|
'expires_at' => Carbon::now()->addMinutes(self::EXPIRATION_MINUTES),
|
|
]);
|
|
|
|
// 저장된 아이템으로 재계산
|
|
$items = $tempOrder->items ?? [];
|
|
$calculationItems = array_map(
|
|
fn (array $item) => CalculationItem::fromArray($item),
|
|
$items
|
|
);
|
|
|
|
// 배송 주소 결정: 파라미터 > 저장된 주소
|
|
$effectiveAddress = $shippingAddress;
|
|
if ($effectiveAddress === null && $tempOrder->getShippingAddress() !== null) {
|
|
$effectiveAddress = ShippingAddress::fromArray($tempOrder->getShippingAddress());
|
|
}
|
|
|
|
// 저장된 프로모션 정보에서 쿠폰 ID 배열 구성
|
|
$promotions = $tempOrder->getPromotions();
|
|
$couponIssueIds = array_filter([
|
|
$promotions['order_coupon_issue_id'] ?? null,
|
|
$promotions['shipping_coupon_issue_id'] ?? null,
|
|
]);
|
|
|
|
$calculationInput = new CalculationInput(
|
|
items: $calculationItems,
|
|
couponIssueIds: $couponIssueIds,
|
|
itemCoupons: $promotions['item_coupons'] ?? [],
|
|
usePoints: $tempOrder->getUsedPoints(),
|
|
shippingAddress: $effectiveAddress,
|
|
userId: $userId,
|
|
);
|
|
|
|
$calculationResult = $this->orderCalculationService->calculate($calculationInput);
|
|
|
|
// 재고/판매상태/구매대상제한 검증 (예외 없이 목록 반환)
|
|
$userRoleIds = $this->purchaseEligibilityService->resolveRoleIds(Auth::user());
|
|
$unavailableItems = $this->checkTempOrderItemsAvailability($tempOrder, $userRoleIds);
|
|
|
|
return [
|
|
'temp_order' => $tempOrder,
|
|
'calculation' => $calculationResult,
|
|
'unavailable_items' => $unavailableItems,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 임시 주문 만료 시간 연장
|
|
*
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @return TempOrder|null 수정된 임시 주문
|
|
*/
|
|
public function extendExpiration(?int $userId, ?string $cartKey): ?TempOrder
|
|
{
|
|
$tempOrder = $this->getTempOrder($userId, $cartKey);
|
|
|
|
if (! $tempOrder) {
|
|
return null;
|
|
}
|
|
|
|
return $this->tempOrderRepository->update($tempOrder, [
|
|
'expires_at' => Carbon::now()->addMinutes(self::EXPIRATION_MINUTES),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 임시 주문 삭제
|
|
*
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @return bool 삭제 성공 여부
|
|
*/
|
|
public function deleteTempOrder(?int $userId, ?string $cartKey): bool
|
|
{
|
|
$tempOrder = $this->tempOrderRepository->findByUserOrCartKey($userId, $cartKey);
|
|
|
|
if (! $tempOrder) {
|
|
return false;
|
|
}
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.before_delete', $tempOrder);
|
|
|
|
$result = $this->tempOrderRepository->delete($tempOrder);
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.after_delete', $userId, $cartKey);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 임시 주문 ID 로 삭제
|
|
*
|
|
* 비회원 PG 결제 완료 시점처럼 user_id 와 cart_key 를 모두 알 수 없는 경우,
|
|
* 주문 생성 시 order_meta 에 보관한 temp_order_id 를 기준으로 임시 주문을 정리합니다.
|
|
*
|
|
* @param int $tempOrderId 임시 주문 ID
|
|
* @return bool 삭제 성공 여부 (대상 없으면 false)
|
|
*/
|
|
public function deleteTempOrderById(int $tempOrderId): bool
|
|
{
|
|
$tempOrder = $this->tempOrderRepository->find($tempOrderId);
|
|
|
|
if (! $tempOrder) {
|
|
return false;
|
|
}
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.before_delete', $tempOrder);
|
|
|
|
$result = $this->tempOrderRepository->delete($tempOrder);
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.after_delete', $tempOrder->user_id, $tempOrder->cart_key);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 만료된 임시 주문 정리
|
|
*
|
|
* @return int 삭제된 개수
|
|
*/
|
|
public function cleanupExpiredTempOrders(): int
|
|
{
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.before_cleanup');
|
|
|
|
$deletedCount = $this->tempOrderRepository->deleteExpired();
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.temp_order.after_cleanup', $deletedCount);
|
|
|
|
return $deletedCount;
|
|
}
|
|
|
|
/**
|
|
* 계산 결과만 조회 (API 응답용)
|
|
*
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @return OrderCalculationResult|null 계산 결과 (임시 주문 없으면 null)
|
|
*/
|
|
public function getCalculationResult(?int $userId, ?string $cartKey): ?OrderCalculationResult
|
|
{
|
|
$tempOrder = $this->getTempOrder($userId, $cartKey);
|
|
|
|
if (! $tempOrder) {
|
|
return null;
|
|
}
|
|
|
|
return OrderCalculationResult::fromArray($tempOrder->calculation_result ?? []);
|
|
}
|
|
|
|
/**
|
|
* 임시 주문 존재 여부 확인
|
|
*
|
|
* @param int|null $userId 회원 ID
|
|
* @param string|null $cartKey 비회원 장바구니 키
|
|
* @return bool 유효한 임시 주문 존재 여부
|
|
*/
|
|
public function hasTempOrder(?int $userId, ?string $cartKey): bool
|
|
{
|
|
return $this->getTempOrder($userId, $cartKey) !== null;
|
|
}
|
|
|
|
/**
|
|
* 장바구니 아이템을 CalculationItem 배열로 변환
|
|
*
|
|
* @param Collection $cartItems 장바구니 아이템 컬렉션
|
|
* @return CalculationItem[]
|
|
*/
|
|
protected function convertCartItemsToCalculationItems(Collection $cartItems): array
|
|
{
|
|
return $cartItems->map(function ($cart) {
|
|
return new CalculationItem(
|
|
productId: $cart->product_id,
|
|
productOptionId: $cart->product_option_id,
|
|
quantity: $cart->quantity,
|
|
cartId: $cart->id,
|
|
additionalOptionSelections: $cart->additional_option_selections,
|
|
);
|
|
})->all();
|
|
}
|
|
|
|
/**
|
|
* 장바구니 아이템을 저장용 배열로 직렬화
|
|
*
|
|
* @param Collection $cartItems 장바구니 아이템 컬렉션
|
|
*/
|
|
protected function serializeCartItems(Collection $cartItems): array
|
|
{
|
|
return $cartItems->map(function ($cart) {
|
|
return [
|
|
'cart_id' => $cart->id,
|
|
'product_id' => $cart->product_id,
|
|
'product_option_id' => $cart->product_option_id,
|
|
'additional_option_selections' => $cart->additional_option_selections,
|
|
'quantity' => $cart->quantity,
|
|
];
|
|
})->all();
|
|
}
|
|
|
|
/**
|
|
* 쿠폰 소유권을 검증하고 유효한 쿠폰만 필터링
|
|
*
|
|
* 비회원은 쿠폰 사용 불가, 회원은 본인 소유 쿠폰만 사용 가능
|
|
*
|
|
* @param int|null $userId 사용자 ID
|
|
* @param array $couponIssueIds 쿠폰 발급 ID 배열
|
|
* @return array 유효한 쿠폰 ID 배열
|
|
*/
|
|
protected function validateAndFilterCoupons(?int $userId, array $couponIssueIds): array
|
|
{
|
|
// 비회원은 쿠폰 사용 불가
|
|
if ($userId === null) {
|
|
return [];
|
|
}
|
|
|
|
// 빈 배열이면 그대로 반환
|
|
if (empty($couponIssueIds)) {
|
|
return [];
|
|
}
|
|
|
|
// 소유권 검증 (본인 소유 쿠폰만 필터링)
|
|
$ownedCoupons = $this->couponIssueRepository->findByIdsForUser($couponIssueIds, $userId);
|
|
$ownedCouponIds = $ownedCoupons->pluck('id')->all();
|
|
|
|
// 요청한 쿠폰 중 본인 소유인 것만 반환
|
|
return array_values(array_intersect($couponIssueIds, $ownedCouponIds));
|
|
}
|
|
|
|
/**
|
|
* 상품별 쿠폰 소유권 검증 및 필터링
|
|
*
|
|
* 비회원은 쿠폰 사용 불가
|
|
* 본인 소유가 아닌 쿠폰은 필터링
|
|
*
|
|
* @param int|null $userId 사용자 ID
|
|
* @param array<int, int[]> $itemCoupons 상품옵션별 쿠폰 [상품옵션ID => [쿠폰발급ID, ...]]
|
|
* @return array<int, int[]> 검증된 상품옵션별 쿠폰
|
|
*/
|
|
protected function validateAndFilterItemCoupons(?int $userId, array $itemCoupons): array
|
|
{
|
|
// 비회원은 쿠폰 사용 불가
|
|
if ($userId === null) {
|
|
return [];
|
|
}
|
|
|
|
// 빈 배열이면 그대로 반환
|
|
if (empty($itemCoupons)) {
|
|
return [];
|
|
}
|
|
|
|
// 모든 쿠폰 ID를 추출
|
|
$allCouponIds = [];
|
|
foreach ($itemCoupons as $couponIds) {
|
|
$allCouponIds = array_merge($allCouponIds, $couponIds);
|
|
}
|
|
$allCouponIds = array_unique($allCouponIds);
|
|
|
|
// 소유권 검증 (본인 소유 쿠폰만 필터링)
|
|
$ownedCoupons = $this->couponIssueRepository->findByIdsForUser($allCouponIds, $userId);
|
|
$ownedCouponIds = $ownedCoupons->pluck('id')->all();
|
|
|
|
// 상품옵션별로 본인 소유 쿠폰만 필터링하여 반환
|
|
$validatedItemCoupons = [];
|
|
foreach ($itemCoupons as $optionId => $couponIds) {
|
|
$validCouponIds = array_values(array_intersect($couponIds, $ownedCouponIds));
|
|
if (! empty($validCouponIds)) {
|
|
$validatedItemCoupons[$optionId] = $validCouponIds;
|
|
}
|
|
}
|
|
|
|
return $validatedItemCoupons;
|
|
}
|
|
|
|
/**
|
|
* 마일리지 사용 검증
|
|
*
|
|
* 비회원은 마일리지 사용 불가. 보유 잔액(원장 기준)을 초과하는 요청은
|
|
* 조용히 클램프하지 않고 명시적으로 차단합니다(의도와 다른 금액으로 결제되는 혼란 제거).
|
|
*
|
|
* @param int|null $userId 사용자 ID
|
|
* @param int $usePoints 사용할 마일리지
|
|
* @return int 검증된 사용 마일리지
|
|
*
|
|
* @throws MileageValidationException 보유 잔액을 초과하여 사용 요청한 경우
|
|
*/
|
|
protected function validatePointsUsage(?int $userId, int $usePoints): int
|
|
{
|
|
// 비회원은 마일리지 사용 불가 (조용히 0 — 에러 아님, 기존 동작 유지)
|
|
if ($userId === null) {
|
|
return 0;
|
|
}
|
|
|
|
$usePoints = max(0, $usePoints);
|
|
if ($usePoints === 0) {
|
|
return 0;
|
|
}
|
|
|
|
// 보유잔액(원장 기준) 초과 요청은 조용히 클램프하지 않고 명시 차단 (U15).
|
|
// 최종 방어는 차감 시점(consumeFifo) 원장 FOR UPDATE 재검증 — 본 검증은 사용자 UX용 선차단.
|
|
// 판정·안내 문구 모두 원장 기준으로 통일(getBalance 캐시 지연으로 인한 어긋남 제거).
|
|
if (! $this->userMileageService->canUse($userId, $usePoints)) {
|
|
throw new MileageValidationException(
|
|
__('sirsoft-ecommerce::exceptions.mileage.use_exceeds_balance', [
|
|
'amount' => $this->userMileageService->availableBalance($userId),
|
|
])
|
|
);
|
|
}
|
|
|
|
return $usePoints;
|
|
}
|
|
|
|
/**
|
|
* 마일리지 사용 정책 검증 (최소 사용액 / 사용 단위 / 최대 한도)
|
|
*
|
|
* validatePointsUsage() 는 계산 전 단계라 결제금액을 알 수 없어 잔액만 판정합니다.
|
|
* 정률 한도(max_use_percent)는 결제금액이 있어야 판정 가능하므로, 계산이 끝난
|
|
* 직후(영속화 전)에 이 검증을 수행합니다. 판정 기준 금액은 마일리지 차감 전
|
|
* 결제금액(summary.paymentAmount)입니다.
|
|
*
|
|
* @param int|null $userId 사용자 ID (비회원은 null)
|
|
* @param int $usePoints 사용 요청 마일리지
|
|
* @param OrderCalculationResult $calculationResult 주문 계산 결과
|
|
*
|
|
* @throws MileageValidationException 정책 위반 시
|
|
*/
|
|
protected function assertPointsWithinUsagePolicy(
|
|
?int $userId,
|
|
int $usePoints,
|
|
OrderCalculationResult $calculationResult
|
|
): void {
|
|
// 비회원은 validatePointsUsage 에서 이미 0 으로 정규화됨
|
|
if ($userId === null || $usePoints <= 0) {
|
|
return;
|
|
}
|
|
|
|
$this->userMileageService->validateUsage(
|
|
$userId,
|
|
$usePoints,
|
|
(int) ($calculationResult->summary->paymentAmount ?? 0)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 단일 쿠폰 소유권 검증
|
|
*
|
|
* @param int|null $userId 사용자 ID
|
|
* @param int|null $couponIssueId 쿠폰 발급 ID
|
|
* @return int|null 검증된 쿠폰 ID (소유하지 않으면 null)
|
|
*/
|
|
protected function validateSingleCoupon(?int $userId, ?int $couponIssueId): ?int
|
|
{
|
|
// 비회원은 쿠폰 사용 불가
|
|
if ($userId === null || $couponIssueId === null) {
|
|
return null;
|
|
}
|
|
|
|
// 소유권 검증
|
|
$ownedCoupons = $this->couponIssueRepository->findByIdsForUser([$couponIssueId], $userId);
|
|
|
|
return $ownedCoupons->isNotEmpty() ? $couponIssueId : null;
|
|
}
|
|
|
|
/**
|
|
* 장바구니 아이템의 재고/판매상태 검증
|
|
*
|
|
* 구매 불가능한 상품(재고 부족, 판매중지)이 있으면 예외를 발생시킵니다.
|
|
*
|
|
* @param Collection $cartItems 장바구니 아이템 컬렉션 (product, productOption 관계 로드 필요)
|
|
* @param array<int, int> $userRoleIds 현재 사용자의 역할 ID 배열 (비회원은 guest 역할 ID)
|
|
*
|
|
* @throws CartUnavailableException 구매 불가능한 상품이 있는 경우
|
|
*/
|
|
protected function validateCartItemsAvailability(Collection $cartItems, array $userRoleIds = []): void
|
|
{
|
|
$unavailableItems = [];
|
|
|
|
// 상품당 총수량 합산 (옵션 분할로 한도 우회 방지) + 대표 라인/상품 보관
|
|
$quantityByProduct = [];
|
|
$representativeByProduct = [];
|
|
|
|
foreach ($cartItems as $item) {
|
|
$product = $item->product;
|
|
$option = $item->productOption;
|
|
|
|
if (! $product || ! $option) {
|
|
continue;
|
|
}
|
|
|
|
$quantityByProduct[$product->id] = ($quantityByProduct[$product->id] ?? 0) + $item->quantity;
|
|
if (! isset($representativeByProduct[$product->id])) {
|
|
$representativeByProduct[$product->id] = ['item' => $item, 'product' => $product, 'option' => $option];
|
|
}
|
|
|
|
// 썸네일 URL 추출
|
|
$thumbnailImage = $product->relationLoaded('images')
|
|
? ($product->images->firstWhere('is_thumbnail', true) ?? $product->images->first())
|
|
: null;
|
|
|
|
// 옵션 값 포맷 (예: "색상: 빨강, 사이즈: L")
|
|
$formattedOption = $this->formatOptionValues($option->getLocalizedOptionValues());
|
|
|
|
// 1. 구매 대상 제한 체크 (역할 기반)
|
|
if (! $this->purchaseEligibilityService->isPurchasableBy($product, $userRoleIds)) {
|
|
$unavailableItems[] = [
|
|
'cart_id' => $item->id,
|
|
'product_id' => $product->id,
|
|
'product_option_id' => $option->id,
|
|
'name' => $product->getLocalizedName(),
|
|
'option' => $formattedOption,
|
|
'thumbnail' => $thumbnailImage?->download_url,
|
|
'quantity' => $item->quantity,
|
|
'stock' => $option->stock_quantity ?? 0,
|
|
'reason' => 'restricted',
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
// 2. 판매상태 체크 (판매중 + 전시중이 아니면 차단 — hidden 포함, isPurchasable 통일)
|
|
if (! $product->isPurchasable()) {
|
|
$unavailableItems[] = [
|
|
'cart_id' => $item->id,
|
|
'product_id' => $product->id,
|
|
'product_option_id' => $option->id,
|
|
'name' => $product->getLocalizedName(),
|
|
'option' => $formattedOption,
|
|
'thumbnail' => $thumbnailImage?->download_url,
|
|
'quantity' => $item->quantity,
|
|
'stock' => $option->stock_quantity ?? 0,
|
|
'reason' => 'status',
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
// 3. 재고 체크 (quantity > stock_quantity)
|
|
$stockQuantity = $option->stock_quantity ?? 0;
|
|
if ($item->quantity > $stockQuantity) {
|
|
$unavailableItems[] = [
|
|
'cart_id' => $item->id,
|
|
'product_id' => $product->id,
|
|
'product_option_id' => $option->id,
|
|
'name' => $product->getLocalizedName(),
|
|
'option' => $formattedOption,
|
|
'thumbnail' => $thumbnailImage?->download_url,
|
|
'quantity' => $item->quantity,
|
|
'stock' => $stockQuantity,
|
|
'reason' => 'stock',
|
|
];
|
|
}
|
|
}
|
|
|
|
// 4. 상품당 총수량 기준 최소/최대 구매수량 검증 (옵션 합산, A25)
|
|
// 이미 차단 사유가 있는 상품은 중복 표기하지 않는다.
|
|
$flaggedProductIds = array_column($unavailableItems, 'product_id');
|
|
foreach ($quantityByProduct as $productId => $totalQuantity) {
|
|
if (in_array($productId, $flaggedProductIds, true)) {
|
|
continue;
|
|
}
|
|
|
|
$rep = $representativeByProduct[$productId];
|
|
$quantityIssue = $this->detectPurchaseQuantityIssue($rep['product'], $totalQuantity);
|
|
if ($quantityIssue === null) {
|
|
continue;
|
|
}
|
|
|
|
$thumbnailImage = $rep['product']->relationLoaded('images')
|
|
? ($rep['product']->images->firstWhere('is_thumbnail', true) ?? $rep['product']->images->first())
|
|
: null;
|
|
|
|
$unavailableItems[] = [
|
|
'cart_id' => $rep['item']->id,
|
|
'product_id' => $productId,
|
|
'product_option_id' => $rep['option']->id,
|
|
'name' => $rep['product']->getLocalizedName(),
|
|
'option' => $this->formatOptionValues($rep['option']->getLocalizedOptionValues()),
|
|
'thumbnail' => $thumbnailImage?->download_url,
|
|
'quantity' => $totalQuantity,
|
|
'requested' => $totalQuantity,
|
|
'limit' => $quantityIssue['limit'],
|
|
'stock' => $rep['option']->stock_quantity ?? 0,
|
|
'reason' => $quantityIssue['reason'],
|
|
];
|
|
}
|
|
|
|
if (! empty($unavailableItems)) {
|
|
throw CartUnavailableException::fromItems($unavailableItems);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 상품 총수량 기준 최소/최대 구매수량 위반 여부를 판정합니다.
|
|
*
|
|
* max_purchase_qty = 0 은 무제한(상한 skip), min_purchase_qty 기본 1.
|
|
*
|
|
* @param Product $product 상품 모델
|
|
* @param int $totalQuantity 상품 총수량 (동일 product 모든 옵션 라인 합산)
|
|
* @return array{reason: string, limit: int}|null 위반 시 사유/한도, 정상 시 null
|
|
*/
|
|
protected function detectPurchaseQuantityIssue($product, int $totalQuantity): ?array
|
|
{
|
|
$min = (int) ($product->min_purchase_qty ?? 1);
|
|
$max = (int) ($product->max_purchase_qty ?? 0);
|
|
|
|
if ($min > 0 && $totalQuantity < $min) {
|
|
return ['reason' => 'min_qty', 'limit' => $min];
|
|
}
|
|
|
|
if ($max > 0 && $totalQuantity > $max) {
|
|
return ['reason' => 'max_qty', 'limit' => $max];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 옵션 값 배열을 포맷된 문자열로 변환
|
|
*
|
|
* @param array $optionValues 옵션 값 배열 (예: ['색상' => '빨강', '사이즈' => 'L'])
|
|
* @return string|null 포맷된 문자열 (예: "색상: 빨강, 사이즈: L") 또는 빈 경우 null
|
|
*/
|
|
protected function formatOptionValues(array $optionValues): ?string
|
|
{
|
|
if (empty($optionValues)) {
|
|
return null;
|
|
}
|
|
|
|
$parts = [];
|
|
foreach ($optionValues as $key => $value) {
|
|
$parts[] = $key.': '.$value;
|
|
}
|
|
|
|
return implode(', ', $parts);
|
|
}
|
|
|
|
/**
|
|
* 임시 주문 아이템의 재고/판매상태 검증 (예외를 발생시키지 않음)
|
|
*
|
|
* 체크아웃 페이지 새로고침 시 현재 재고 상황을 확인하고
|
|
* 구매 불가능한 상품 목록을 반환합니다.
|
|
*
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
* @param array<int, int> $userRoleIds 현재 사용자의 역할 ID 배열 (비회원은 guest 역할 ID)
|
|
* @return array 구매불가 상품 목록 (없으면 빈 배열)
|
|
*/
|
|
public function checkTempOrderItemsAvailability(TempOrder $tempOrder, array $userRoleIds = []): array
|
|
{
|
|
$items = $tempOrder->items ?? [];
|
|
if (empty($items)) {
|
|
return [];
|
|
}
|
|
|
|
// 상품 옵션 ID 추출
|
|
$optionIds = array_filter(array_column($items, 'product_option_id'));
|
|
if (empty($optionIds)) {
|
|
return [];
|
|
}
|
|
|
|
// Repository를 통해 옵션 정보 조회 (상품, 이미지 관계 포함)
|
|
$options = $this->productOptionRepository->findByIdsWithProduct($optionIds);
|
|
$optionsById = $options->keyBy('id');
|
|
|
|
$unavailableItems = [];
|
|
|
|
foreach ($items as $item) {
|
|
$optionId = $item['product_option_id'] ?? null;
|
|
if (! $optionId || ! isset($optionsById[$optionId])) {
|
|
continue;
|
|
}
|
|
|
|
/** @var ProductOption $option */
|
|
$option = $optionsById[$optionId];
|
|
$product = $option->product;
|
|
|
|
if (! $product) {
|
|
continue;
|
|
}
|
|
|
|
$quantity = $item['quantity'] ?? 1;
|
|
$stockQuantity = $option->stock_quantity ?? 0;
|
|
|
|
// 썸네일 URL 추출
|
|
$thumbnailImage = $product->relationLoaded('images')
|
|
? ($product->images->firstWhere('is_thumbnail', true) ?? $product->images->first())
|
|
: null;
|
|
|
|
// 옵션 값 포맷 (예: "색상: 빨강, 사이즈: L")
|
|
$formattedOption = $this->formatOptionValues($option->getLocalizedOptionValues());
|
|
|
|
// 1. 구매 대상 제한 체크 (역할 기반)
|
|
if (! $this->purchaseEligibilityService->isPurchasableBy($product, $userRoleIds)) {
|
|
$unavailableItems[] = [
|
|
'cart_id' => $item['cart_id'] ?? null,
|
|
'product_id' => $product->id,
|
|
'product_option_id' => $optionId,
|
|
'name' => $product->getLocalizedName(),
|
|
'option' => $formattedOption,
|
|
'thumbnail' => $thumbnailImage?->download_url,
|
|
'quantity' => $quantity,
|
|
'stock' => $stockQuantity,
|
|
'reason' => 'restricted',
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
// 2. 판매상태 체크 (판매중 + 전시중이 아니면 차단 — hidden 포함, isPurchasable 통일)
|
|
if (! $product->isPurchasable()) {
|
|
$unavailableItems[] = [
|
|
'cart_id' => $item['cart_id'] ?? null,
|
|
'product_id' => $product->id,
|
|
'product_option_id' => $optionId,
|
|
'name' => $product->getLocalizedName(),
|
|
'option' => $formattedOption,
|
|
'thumbnail' => $thumbnailImage?->download_url,
|
|
'quantity' => $quantity,
|
|
'stock' => $stockQuantity,
|
|
'reason' => 'status',
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
// 3. 재고 체크
|
|
if ($quantity > $stockQuantity) {
|
|
$unavailableItems[] = [
|
|
'cart_id' => $item['cart_id'] ?? null,
|
|
'product_id' => $product->id,
|
|
'product_option_id' => $optionId,
|
|
'name' => $product->getLocalizedName(),
|
|
'option' => $formattedOption,
|
|
'thumbnail' => $thumbnailImage?->download_url,
|
|
'quantity' => $quantity,
|
|
'stock' => $stockQuantity,
|
|
'reason' => 'stock',
|
|
];
|
|
}
|
|
}
|
|
|
|
return $unavailableItems;
|
|
}
|
|
}
|