구간별 배송비의 종료값이 구간에 포함되지 않아 경계값이 무료배송으로 새고, 상품 옵션의 g/cm³ 저장값을 정책의 kg/L 로 환산하지 않아 금액이 1000배로 청구되던 문제를 수정한다. - 구간 매칭을 종료값 오름차순 사다리로 교체 — 시작값은 표시 전용이 되어 기존 저장 데이터의 두 형태 모두에서 정확히 계산된다 - 단위 환산을 배송비 계산 한 지점으로 모으고, 부피무게와 실무게를 같은 kg 단위로 비교하도록 교정 - 계산이 조용히 0원이 되는 설정(구간 미등록·단위값/기준금액 누락· 중간 구간 무제한·계산 API 폴백 0원)을 저장 시점에 차단하고, 런타임 잔여 경로에는 경고 로그를 남긴다 - 연속성 규칙을 정책 타입별로 분리해 소수 구간(2.5kg) 저장을 허용 - 상품 옵션 무게/부피 응답 왕복, 주문 총 무게/부피 실값 기록
2864 lines
125 KiB
PHP
2864 lines
125 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Services;
|
|
|
|
use App\Extension\HookManager;
|
|
use App\Support\OutboundUrlValidator;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Modules\Sirsoft\Ecommerce\DTO\AppliedPromotions;
|
|
use Modules\Sirsoft\Ecommerce\DTO\AppliedShippingPolicy;
|
|
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
|
|
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
|
|
use Modules\Sirsoft\Ecommerce\DTO\CouponApplication;
|
|
use Modules\Sirsoft\Ecommerce\DTO\ItemCalculation;
|
|
use Modules\Sirsoft\Ecommerce\DTO\MultiCurrencyPrices;
|
|
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
|
|
use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary;
|
|
use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress;
|
|
use Modules\Sirsoft\Ecommerce\DTO\SnapshotProduct;
|
|
use Modules\Sirsoft\Ecommerce\DTO\SnapshotProductOption;
|
|
use Modules\Sirsoft\Ecommerce\DTO\Summary;
|
|
use Modules\Sirsoft\Ecommerce\DTO\ValidationError;
|
|
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\CouponDiscountType;
|
|
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
|
|
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
|
|
use Modules\Sirsoft\Ecommerce\Enums\ProductTaxStatus;
|
|
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiAuthType;
|
|
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiHttpMethod;
|
|
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiResponseType;
|
|
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
|
|
use Modules\Sirsoft\Ecommerce\Models\Coupon;
|
|
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
|
|
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
|
|
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicyCountrySetting;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingPolicyRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
|
use Modules\Sirsoft\Ecommerce\Support\VatCalculator;
|
|
|
|
/**
|
|
* 주문 계산 서비스
|
|
*
|
|
* 9단계 계산 로직을 구현하는 핵심 서비스입니다.
|
|
*
|
|
* 사용처:
|
|
* - 상품상세: 자동할인쿠폰, 유저 기본배송지에 따른 배송비 계산
|
|
* - 장바구니: 자동할인쿠폰, 유저 기본배송지에 따른 배송비 계산
|
|
* - 체크아웃: 쿠폰/마일리지 적용, 최종 결제금액 계산
|
|
* - 주문서 생성: 최종 결제금액 확정
|
|
* - 결제완료 처리: 최종 결제금액 검증
|
|
* - 부분취소: 취소 금액 계산
|
|
*
|
|
* 계산 순서:
|
|
* 1. 옵션별 판매금액 계산
|
|
* 2. 상품/카테고리 할인쿠폰 적용
|
|
* 2-a. 옵션별 적립 마일리지 계산
|
|
* 2-b. 과세/면세 금액 분류
|
|
* 3. 배송비 계산 (정책별 그룹)
|
|
* 4. 배송비 할인쿠폰 적용
|
|
* 5. 주문금액 할인쿠폰 적용
|
|
* 6. 적립 마일리지 합계
|
|
* 7. 최종 결제금액 계산
|
|
* 8. 마일리지 사용 차감
|
|
* 9. 최종 지불금액 계산
|
|
*
|
|
* @todo 결제완료 검증 시 Order 모델에서 CalculationInput 복원 메서드 필요
|
|
* - Order::toCalculationInput() 또는
|
|
* - OrderCalculationService::fromOrder(Order $order) 정적 메서드
|
|
* @todo 부분취소는 별도 RefundCalculationService로 분리 권장
|
|
* - 이미 적용된 할인의 안분 비율 계산
|
|
* - 마일리지 환불 처리
|
|
* - 배송비 환불 여부 판단
|
|
*/
|
|
class OrderCalculationService
|
|
{
|
|
/**
|
|
* @param CurrencyConversionService $currencyService 통화 변환 서비스
|
|
* @param ProductOptionRepositoryInterface $productOptionRepository 상품 옵션 Repository
|
|
* @param CouponIssueRepositoryInterface $couponIssueRepository 쿠폰 발급 Repository
|
|
* @param ShippingPolicyRepositoryInterface $shippingPolicyRepository 배송정책 Repository
|
|
* @param EcommerceSettingsService $settingsService 이커머스 설정 서비스
|
|
* @param ProductAdditionalOptionValueRepositoryInterface $additionalOptionValueRepository 추가옵션 선택지 Repository
|
|
*/
|
|
public function __construct(
|
|
protected CurrencyConversionService $currencyService,
|
|
protected ProductOptionRepositoryInterface $productOptionRepository,
|
|
protected CouponIssueRepositoryInterface $couponIssueRepository,
|
|
protected ShippingPolicyRepositoryInterface $shippingPolicyRepository,
|
|
protected EcommerceSettingsService $settingsService,
|
|
protected ProductAdditionalOptionValueRepositoryInterface $additionalOptionValueRepository,
|
|
protected ShippingPolicyResolver $shippingPolicyResolver,
|
|
) {}
|
|
|
|
/**
|
|
* 주문 계산 실행 (9단계)
|
|
*
|
|
* @param CalculationInput $input 계산 입력 데이터
|
|
* @return OrderCalculationResult 계산 결과
|
|
*/
|
|
public function calculate(CalculationInput $input): OrderCalculationResult
|
|
{
|
|
// 아이템 데이터 준비 (상품/옵션 정보 로드 또는 스냅샷 사용)
|
|
$preparedItems = $this->prepareItems($input->items, $input);
|
|
|
|
if (empty($preparedItems)) {
|
|
return new OrderCalculationResult;
|
|
}
|
|
|
|
// 쿠폰 정보 로드 (주문/배송비 쿠폰)
|
|
$coupons = $this->loadCoupons($input->couponIssueIds);
|
|
|
|
// 상품별 쿠폰 정보 로드
|
|
$itemCouponIds = $this->extractCouponIdsFromItemCoupons($input->itemCoupons);
|
|
$itemCouponIssues = $this->loadCoupons($itemCouponIds);
|
|
|
|
$validationErrors = [];
|
|
|
|
// 쿠폰 조합 가능 여부 검증 (is_combinable) — 전체 슬롯 통합 1회 (A15/MP06)
|
|
// 슬롯별 독립 검증은 "상품별(false) 1개 + 주문(true) 1개" 같은 슬롯 교차 조합을
|
|
// 각 슬롯 count=1 이라 놓친다. 적용 대상 전체 쿠폰을 한 집합으로 모아 1회 검증한다.
|
|
// 스냅샷 모드(환불 재계산)에서도 동일 검증한다(2026-06-19 확정).
|
|
$allAppliedCoupons = $this->collectAllCouponsForCombination($coupons, $itemCouponIssues);
|
|
$combinationViolations = $this->findNonCombinableViolations($allAppliedCoupons);
|
|
foreach ($combinationViolations as $violationIssue) {
|
|
$validationErrors[] = ValidationError::notCombinable($violationIssue->coupon->id);
|
|
}
|
|
// 위반(중복불가) 쿠폰은 할인 적용에서 제외 — 에러만 담고 할인은 먹는 모순 방지 (소프트 표면화/MP06).
|
|
// 적용 입력(주문/배송/상품 슬롯 + itemCoupons 매핑)에서 위반 발급ID 를 미리 걸러낸다.
|
|
// 단, 스냅샷 모드(환불 재계산)는 이미 확정된 주문이 실제로 적용한 쿠폰을 그대로 재현해야 하므로
|
|
// 제외하지 않는다(검증 오류는 위에서 동일하게 표면화). 위반 조합은 주문 확정 시점 422 하드 차단으로
|
|
// 애초에 확정될 수 없으므로 정상 흐름에서 스냅샷에 위반 조합이 존재하지 않는다.
|
|
$combinationSnapshotMode = $input->metadata['snapshot_mode'] ?? false;
|
|
if (! empty($combinationViolations) && ! $combinationSnapshotMode) {
|
|
$excludedCouponIssueIds = array_map(fn ($issue) => $issue->id, $combinationViolations);
|
|
$coupons = array_values(array_filter(
|
|
$coupons,
|
|
fn ($issue) => ! in_array($issue->id, $excludedCouponIssueIds, true)
|
|
));
|
|
$itemCouponIssues = array_values(array_filter(
|
|
$itemCouponIssues,
|
|
fn ($issue) => ! in_array($issue->id, $excludedCouponIssueIds, true)
|
|
));
|
|
$filteredItemCoupons = [];
|
|
foreach ($input->itemCoupons as $optionId => $issueIds) {
|
|
$kept = array_values(array_filter(
|
|
(array) $issueIds,
|
|
fn ($issueId) => ! in_array((int) $issueId, $excludedCouponIssueIds, true)
|
|
));
|
|
if (! empty($kept)) {
|
|
$filteredItemCoupons[$optionId] = $kept;
|
|
}
|
|
}
|
|
$input->itemCoupons = $filteredItemCoupons;
|
|
}
|
|
|
|
// 단계 1: 옵션별 판매금액 계산
|
|
// Before: 단가/수량 조작 가능 (회원등급 할인, 프로모션 단가 등)
|
|
$preparedItems = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.before_item_subtotals',
|
|
$preparedItems,
|
|
$input
|
|
);
|
|
$itemSubtotals = $this->calculateItemSubtotals($preparedItems);
|
|
// After: 계산된 소계 수정 가능
|
|
$itemSubtotals = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_item_subtotals',
|
|
$itemSubtotals,
|
|
$preparedItems
|
|
);
|
|
|
|
// 단계 2: 상품/카테고리 할인쿠폰 적용
|
|
$productCoupons = $this->filterCouponsByType($coupons, CouponTargetType::PRODUCT_AMOUNT);
|
|
// Before: 쿠폰 목록 조작 가능 (자동 쿠폰 주입 등)
|
|
$productCoupons = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.before_product_discount',
|
|
$productCoupons,
|
|
$itemSubtotals,
|
|
$preparedItems
|
|
);
|
|
[$itemsAfterProductDiscount, $productPromotions, $productErrors] = $this->applyProductCoupons(
|
|
$itemSubtotals,
|
|
$productCoupons,
|
|
$preparedItems,
|
|
$input->itemCoupons,
|
|
$itemCouponIssues,
|
|
$input
|
|
);
|
|
// After: 할인 결과 수정 가능
|
|
$itemsAfterProductDiscount = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_product_discount',
|
|
$itemsAfterProductDiscount,
|
|
$productCoupons,
|
|
$preparedItems
|
|
);
|
|
$validationErrors = array_merge($validationErrors, $productErrors);
|
|
|
|
// 단계 2-b: 과세/면세 금액 분류
|
|
$taxClassification = $this->classifyTaxStatus($preparedItems, $itemsAfterProductDiscount);
|
|
$taxClassification = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_tax_classification',
|
|
$taxClassification,
|
|
$preparedItems,
|
|
$itemsAfterProductDiscount
|
|
);
|
|
|
|
// 단계 3: 배송비 계산 (정책별 그룹)
|
|
// Before: 배송정책 그룹 조작 가능 (지역별 정책 변경 등)
|
|
$shippingContext = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.before_shipping_fee',
|
|
[
|
|
'prepared_items' => $preparedItems,
|
|
'discounted_items' => $itemsAfterProductDiscount,
|
|
'shipping_address' => $input->shippingAddress,
|
|
],
|
|
$input
|
|
);
|
|
$shippingCalculation = $this->calculateShippingFee(
|
|
$shippingContext['prepared_items'],
|
|
$shippingContext['discounted_items'],
|
|
$shippingContext['shipping_address'] ?? $input->shippingAddress,
|
|
$input
|
|
);
|
|
// After: 계산된 배송비 수정 가능
|
|
$shippingCalculation = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_shipping_fee',
|
|
$shippingCalculation,
|
|
$preparedItems,
|
|
$itemsAfterProductDiscount
|
|
);
|
|
|
|
// 단계 4: 배송비 할인쿠폰 적용
|
|
$shippingCoupons = $this->filterCouponsByType($coupons, CouponTargetType::SHIPPING_FEE);
|
|
// Before: 배송비 쿠폰 목록 조작 가능
|
|
$shippingCoupons = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.before_shipping_discount',
|
|
$shippingCoupons,
|
|
$shippingCalculation
|
|
);
|
|
[$shippingAfterDiscount, $shippingPromotions, $shippingErrors] = $this->applyShippingCoupon(
|
|
$shippingCalculation,
|
|
$shippingCoupons,
|
|
$input
|
|
);
|
|
// After: 배송비 할인 결과 수정 가능
|
|
$shippingAfterDiscount = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_shipping_discount',
|
|
$shippingAfterDiscount,
|
|
$shippingCoupons
|
|
);
|
|
$validationErrors = array_merge($validationErrors, $shippingErrors);
|
|
|
|
// 단계 5: 주문금액 할인쿠폰 적용
|
|
$orderCoupons = $this->filterCouponsByType($coupons, CouponTargetType::ORDER_AMOUNT);
|
|
// Before: 주문 쿠폰 목록 조작 가능
|
|
$orderCoupons = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.before_order_discount',
|
|
$orderCoupons,
|
|
$itemsAfterProductDiscount,
|
|
$preparedItems
|
|
);
|
|
[$itemsAfterOrderDiscount, $orderPromotions, $orderErrors] = $this->applyOrderCoupon(
|
|
$itemsAfterProductDiscount,
|
|
$orderCoupons,
|
|
$preparedItems,
|
|
$input
|
|
);
|
|
// After: 주문 할인 결과 수정 가능
|
|
$itemsAfterOrderDiscount = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_order_discount',
|
|
$itemsAfterOrderDiscount,
|
|
$orderCoupons,
|
|
$preparedItems
|
|
);
|
|
$validationErrors = array_merge($validationErrors, $orderErrors);
|
|
|
|
// 단계 6: 최종 결제금액 계산
|
|
// Before: 결제금액 계산 전 데이터 조작 가능 (수수료 추가 등)
|
|
$paymentContext = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.before_payment_amount',
|
|
[
|
|
'discounted_items' => $itemsAfterOrderDiscount,
|
|
'shipping_result' => $shippingAfterDiscount,
|
|
],
|
|
$input
|
|
);
|
|
$paymentCalculation = $this->calculatePaymentAmount(
|
|
$paymentContext['discounted_items'],
|
|
$paymentContext['shipping_result']
|
|
);
|
|
// After: 계산된 결제금액 수정 가능
|
|
$paymentCalculation = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_payment_amount',
|
|
$paymentCalculation,
|
|
$itemsAfterOrderDiscount,
|
|
$shippingAfterDiscount
|
|
);
|
|
|
|
// 단계 7: 마일리지 사용 차감
|
|
$pointsUsageResult = $this->applyPointsUsage(
|
|
$paymentCalculation['payment_amount'],
|
|
$input->usePoints,
|
|
$itemsAfterOrderDiscount
|
|
);
|
|
// After: 마일리지 사용 후 추가 결제 수단 처리 가능 (예치금, 상품권 등)
|
|
$pointsUsageResult = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_points_usage',
|
|
$pointsUsageResult,
|
|
$paymentCalculation['payment_amount'],
|
|
$itemsAfterOrderDiscount,
|
|
$input
|
|
);
|
|
|
|
// 단계 8: 적립 마일리지 계산 (마일리지 사용 후 금액 기준)
|
|
$pointsPerItem = $this->calculatePointsEarning(
|
|
$preparedItems,
|
|
$itemsAfterOrderDiscount,
|
|
$pointsUsageResult,
|
|
$input
|
|
);
|
|
$pointsPerItem = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_points_earning',
|
|
$pointsPerItem,
|
|
$preparedItems,
|
|
$itemsAfterOrderDiscount,
|
|
$pointsUsageResult
|
|
);
|
|
$totalPointsEarning = $this->sumPointsEarning($pointsPerItem);
|
|
|
|
// 단계 9: 최종 지불금액 계산
|
|
// Before: 최종 결과 빌드 전 데이터 조작 가능 (마일리지 사용 수정 등)
|
|
$finalContext = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.before_final_result',
|
|
[
|
|
'prepared_items' => $preparedItems,
|
|
'item_subtotals' => $itemSubtotals,
|
|
'items_after_product_discount' => $itemsAfterProductDiscount,
|
|
'items_after_order_discount' => $itemsAfterOrderDiscount,
|
|
'points_per_item' => $pointsPerItem,
|
|
'tax_classification' => $taxClassification,
|
|
'shipping_result' => $shippingAfterDiscount,
|
|
'points_usage_result' => $pointsUsageResult,
|
|
'payment_calculation' => $paymentCalculation,
|
|
],
|
|
$input
|
|
);
|
|
$finalResult = $this->buildFinalResult(
|
|
$finalContext['prepared_items'],
|
|
$finalContext['item_subtotals'],
|
|
$finalContext['items_after_product_discount'],
|
|
$finalContext['items_after_order_discount'],
|
|
$finalContext['points_per_item'],
|
|
$finalContext['tax_classification'],
|
|
$finalContext['shipping_result'],
|
|
$finalContext['points_usage_result'],
|
|
$finalContext['payment_calculation'],
|
|
$productPromotions,
|
|
$orderPromotions,
|
|
$shippingPromotions,
|
|
$totalPointsEarning,
|
|
$validationErrors,
|
|
$input
|
|
);
|
|
// After: 최종 결과 수정 가능
|
|
// - 플러그인에서 metadata 추가 (리워드, 예치금, 회원등급 할인 등)
|
|
// - 검증 오류 추가
|
|
// - items[].metadata, summary.metadata, metadata 모두 수정 가능
|
|
$finalResult = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.after_final_result',
|
|
$finalResult,
|
|
$input
|
|
);
|
|
|
|
return $finalResult;
|
|
}
|
|
|
|
/**
|
|
* 계산용 아이템 데이터를 준비합니다.
|
|
*
|
|
* 스냅샷이 있으면 DB 조회 대신 스냅샷 데이터를 사용합니다 (환불 재계산용).
|
|
*
|
|
* @param CalculationItem[] $items 계산 아이템 목록
|
|
* @param CalculationInput|null $input 계산 입력 (스냅샷 모드 판별용)
|
|
* @return array 상품/옵션 정보가 로드된 아이템 배열
|
|
*/
|
|
protected function prepareItems(array $items, ?CalculationInput $input = null): array
|
|
{
|
|
$preparedItems = [];
|
|
$shippingPolicySnapshots = $input?->shippingPolicySnapshots ?? [];
|
|
|
|
foreach ($items as $item) {
|
|
// 스냅샷 모드: 주문 시점 데이터로 구성 (DB 조회 없음)
|
|
if ($item->productSnapshot !== null && $item->optionSnapshot !== null) {
|
|
$shippingPolicyId = null;
|
|
if (isset($shippingPolicySnapshots[$item->productOptionId]['policy_id'])) {
|
|
$shippingPolicyId = (int) $shippingPolicySnapshots[$item->productOptionId]['policy_id'];
|
|
}
|
|
|
|
$snapshotProduct = new SnapshotProduct($item->productSnapshot, $shippingPolicyId);
|
|
$snapshotOption = new SnapshotProductOption($item->optionSnapshot, $snapshotProduct->selling_price);
|
|
|
|
// 스냅샷 모드(환불 재계산): 주문 시점 동결된 추가옵션 스냅샷을 그대로 사용
|
|
$additionalSnapshot = $item->additionalOptionsSnapshot ?? [];
|
|
$additionalTotal = $this->sumAdditionalOptionsSnapshot($additionalSnapshot);
|
|
|
|
$preparedItems[] = [
|
|
'cart_id' => $item->cartId,
|
|
'product_id' => $item->productId,
|
|
'product_option_id' => $item->productOptionId,
|
|
'quantity' => $item->quantity,
|
|
'product' => $snapshotProduct,
|
|
'product_option' => $snapshotOption,
|
|
'unit_price' => $snapshotOption->getSellingPrice(),
|
|
'additional_options_total' => $additionalTotal,
|
|
'additional_options_snapshot' => $additionalSnapshot,
|
|
'_snapshot_mode' => true,
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
// 통상 모드: DB에서 상품/옵션 정보 로드
|
|
$productOption = $this->productOptionRepository->findWithRelations(
|
|
$item->productOptionId,
|
|
['product.categories', 'product.shippingPolicy']
|
|
);
|
|
|
|
if (! $productOption || ! $productOption->product) {
|
|
continue;
|
|
}
|
|
|
|
// 추가옵션: value_id 기준 서버 재조회 (클라 가격 신뢰 금지)
|
|
[$additionalTotal, $additionalSnapshot] = $this->resolveAdditionalOptions(
|
|
$item->productId,
|
|
$item->additionalOptionSelections ?? []
|
|
);
|
|
|
|
$preparedItems[] = [
|
|
'cart_id' => $item->cartId,
|
|
'product_id' => $item->productId,
|
|
'product_option_id' => $item->productOptionId,
|
|
'quantity' => $item->quantity,
|
|
'product' => $productOption->product,
|
|
'product_option' => $productOption,
|
|
'unit_price' => $productOption->getSellingPrice(),
|
|
'additional_options_total' => $additionalTotal,
|
|
'additional_options_snapshot' => $additionalSnapshot,
|
|
];
|
|
}
|
|
|
|
return $preparedItems;
|
|
}
|
|
|
|
/**
|
|
* 추가옵션 선택을 서버에서 재조회하여 단위 합계와 스냅샷을 산출합니다.
|
|
*
|
|
* value_id 기준으로 활성·해당 상품 소속인 선택지만 인정하며, 가격은 항상
|
|
* 서버 DB 값(KRW)을 사용합니다. 잘못된/비활성/타상품 value_id 는 무시됩니다.
|
|
* 필수 그룹 미선택 등 차단 검증은 담기/주문 검증 계층(서버 SSoT)이 담당합니다.
|
|
*
|
|
* 직접입력 텍스트(custom_text)는 가격에 무관하며, 선택의 value_id 기준으로
|
|
* 해당 선택지 스냅샷에 동결 병합됩니다(E3).
|
|
*
|
|
* @param int $productId 상품 ID
|
|
* @param array $selections 추가옵션 선택 [{additional_option_id, value_id, custom_text?}]
|
|
* @return array{0: int, 1: array} [단위당 추가옵션 합계, 추가옵션 스냅샷]
|
|
*/
|
|
protected function resolveAdditionalOptions(int $productId, array $selections): array
|
|
{
|
|
if (empty($selections)) {
|
|
return [0, []];
|
|
}
|
|
|
|
// value_id => custom_text 매핑 (직접입력 텍스트 동결용)
|
|
$valueIds = [];
|
|
$customTextByValueId = [];
|
|
foreach ($selections as $selection) {
|
|
$valueId = (int) ($selection['value_id'] ?? 0);
|
|
if ($valueId > 0) {
|
|
$valueIds[] = $valueId;
|
|
$customText = trim((string) ($selection['custom_text'] ?? ''));
|
|
if ($customText !== '') {
|
|
$customTextByValueId[$valueId] = $customText;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($valueIds)) {
|
|
return [0, []];
|
|
}
|
|
|
|
$values = $this->additionalOptionValueRepository->findActiveByIds($valueIds);
|
|
|
|
$total = 0;
|
|
$snapshot = [];
|
|
foreach ($values as $value) {
|
|
// 소속 검증: 선택지의 그룹이 이 상품에 속해야 함 (D12)
|
|
if ($value->additionalOption?->product_id !== $productId) {
|
|
continue;
|
|
}
|
|
|
|
$total += $value->getPriceAdjustment();
|
|
|
|
$entry = $value->toSnapshotArray();
|
|
// 직접입력 텍스트 동결: allow_custom_text 선택지에 한해 주입 (E3)
|
|
if ($value->allow_custom_text && isset($customTextByValueId[$value->id])) {
|
|
$entry['custom_text'] = $customTextByValueId[$value->id];
|
|
}
|
|
$snapshot[] = $entry;
|
|
}
|
|
|
|
return [$total, $snapshot];
|
|
}
|
|
|
|
/**
|
|
* 추가옵션 스냅샷에서 단위당 합계(KRW)를 산출합니다.
|
|
*
|
|
* @param array $snapshot 추가옵션 스냅샷 [{price_adjustment, ...}]
|
|
* @return int 단위당 추가옵션 합계
|
|
*/
|
|
protected function sumAdditionalOptionsSnapshot(array $snapshot): int
|
|
{
|
|
$total = 0;
|
|
foreach ($snapshot as $entry) {
|
|
$total += (int) ($entry['price_adjustment'] ?? 0);
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
/**
|
|
* 쿠폰 정보를 로드합니다.
|
|
*
|
|
* @param int[] $couponIssueIds 쿠폰 발급 ID 배열
|
|
* @return CouponIssue[] 쿠폰 발급 정보 배열
|
|
*/
|
|
protected function loadCoupons(array $couponIssueIds): array
|
|
{
|
|
if (empty($couponIssueIds)) {
|
|
return [];
|
|
}
|
|
|
|
return $this->couponIssueRepository->findByIdsWithRelations(
|
|
$couponIssueIds,
|
|
['coupon.includedProducts', 'coupon.excludedProducts', 'coupon.includedCategories', 'coupon.excludedCategories']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 쿠폰을 타입별로 필터링합니다.
|
|
*
|
|
* @param CouponIssue[] $coupons 쿠폰 발급 목록
|
|
* @param CouponTargetType $type 쿠폰 적용 대상 타입
|
|
* @return CouponIssue[] 필터링된 쿠폰 목록
|
|
*/
|
|
protected function filterCouponsByType(array $coupons, CouponTargetType $type): array
|
|
{
|
|
return array_filter($coupons, fn (CouponIssue $issue) => $issue->coupon->target_type === $type);
|
|
}
|
|
|
|
/**
|
|
* 단계 1: 옵션별 판매금액을 계산합니다.
|
|
*
|
|
* @param array $preparedItems 준비된 아이템 배열
|
|
* @return array 옵션별 소계 배열 [product_option_id => subtotal]
|
|
*/
|
|
protected function calculateItemSubtotals(array $preparedItems): array
|
|
{
|
|
$subtotals = [];
|
|
|
|
foreach ($preparedItems as $item) {
|
|
// 안B: unit_price 는 원옵션가 유지, 추가옵션은 단위 합계로 분리 보유.
|
|
// subtotal = (원옵션가 + 추가옵션 단위 합계) × 수량 (D6)
|
|
$additionalTotal = (int) ($item['additional_options_total'] ?? 0);
|
|
$subtotal = ($item['unit_price'] + $additionalTotal) * $item['quantity'];
|
|
$subtotals[$item['product_option_id']] = [
|
|
'unit_price' => $item['unit_price'],
|
|
'additional_options_total' => $additionalTotal,
|
|
'quantity' => $item['quantity'],
|
|
'subtotal' => $subtotal,
|
|
];
|
|
}
|
|
|
|
return $subtotals;
|
|
}
|
|
|
|
/**
|
|
* itemCoupons에서 모든 쿠폰 발급 ID를 추출합니다.
|
|
*
|
|
* @param array<int, int[]> $itemCoupons 상품옵션별 쿠폰 매핑
|
|
* @return int[] 쿠폰 발급 ID 배열
|
|
*/
|
|
protected function extractCouponIdsFromItemCoupons(array $itemCoupons): array
|
|
{
|
|
$allCouponIds = [];
|
|
foreach ($itemCoupons as $couponIds) {
|
|
$allCouponIds = array_merge($allCouponIds, $couponIds);
|
|
}
|
|
|
|
return array_values(array_unique($allCouponIds));
|
|
}
|
|
|
|
/**
|
|
* 단계 2: 상품/카테고리 할인쿠폰을 적용합니다.
|
|
*
|
|
* 스냅샷 모드에서는 쿠폰 규칙(할인값/유형)을 스냅샷 값으로 사용하고,
|
|
* 스코프 검증(상품/카테고리 범위)을 스킵합니다.
|
|
*
|
|
* @param array $itemSubtotals 옵션별 소계 배열
|
|
* @param array $coupons 상품쿠폰 목록 (주문 레벨에서 지정된 상품 쿠폰)
|
|
* @param array $preparedItems 준비된 아이템 배열
|
|
* @param array<int, int[]> $itemCoupons 상품옵션별 쿠폰 매핑 [상품옵션ID => [쿠폰발급ID, ...]]
|
|
* @param CouponIssue[] $itemCouponIssues 상품별 쿠폰 발급 정보
|
|
* @param CalculationInput|null $input 계산 입력 (스냅샷 모드용)
|
|
* @return array [아이템별 할인 후 금액, AppliedPromotions, ValidationError[]]
|
|
*/
|
|
protected function applyProductCoupons(
|
|
array $itemSubtotals,
|
|
array $coupons,
|
|
array $preparedItems,
|
|
array $itemCoupons = [],
|
|
array $itemCouponIssues = [],
|
|
?CalculationInput $input = null
|
|
): array {
|
|
$discountedItems = $itemSubtotals;
|
|
$appliedPromotions = new AppliedPromotions;
|
|
$validationErrors = [];
|
|
|
|
$snapshotMode = $input->metadata['snapshot_mode'] ?? false;
|
|
$couponSnapshots = $input->metadata['coupon_snapshots'] ?? [];
|
|
|
|
// per_user_limit: 이번 주문에서 동일 coupon_id 적용 누적 (축2 — 주문 내 중복).
|
|
// 상품별 슬롯(itemCoupons)·주문레벨 상품 슬롯에서 공유한다 (U13b/MP06).
|
|
$couponUsageCount = [];
|
|
|
|
// 상품별 쿠폰 적용 (itemCoupons)
|
|
if (! empty($itemCoupons) && ! empty($itemCouponIssues)) {
|
|
// 쿠폰 발급 ID를 키로 하는 맵 생성
|
|
$couponIssueMap = [];
|
|
foreach ($itemCouponIssues as $couponIssue) {
|
|
$couponIssueMap[$couponIssue->id] = $couponIssue;
|
|
}
|
|
|
|
// 각 상품옵션에 지정된 쿠폰 적용
|
|
foreach ($itemCoupons as $optionId => $couponIds) {
|
|
foreach ($couponIds as $couponIssueId) {
|
|
if (! isset($couponIssueMap[$couponIssueId])) {
|
|
continue;
|
|
}
|
|
|
|
$couponIssue = $couponIssueMap[$couponIssueId];
|
|
$coupon = $couponIssue->coupon;
|
|
$snapshot = $couponSnapshots[$couponIssueId] ?? null;
|
|
|
|
// 상품 쿠폰 min_order_amount: 적용 대상 옵션 소계 기준 (U13b/MP06)
|
|
$applicableSubtotal = (int) (
|
|
$discountedItems[$optionId]['discounted_subtotal']
|
|
?? $discountedItems[$optionId]['subtotal']
|
|
?? 0
|
|
);
|
|
|
|
// 쿠폰 검증
|
|
$error = $this->validateCoupon(
|
|
$couponIssue,
|
|
$itemSubtotals,
|
|
$preparedItems,
|
|
$snapshotMode,
|
|
(int) ($snapshot['min_order_amount'] ?? 0),
|
|
$applicableSubtotal
|
|
);
|
|
if ($error !== null) {
|
|
$validationErrors[] = $error;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 사용자별 사용 한도 검증 (per_user_limit 축1+축2 — U13b/MP06)
|
|
$limitError = $this->checkPerUserLimit($couponIssue, $input, $snapshotMode, $couponUsageCount);
|
|
if ($limitError !== null) {
|
|
$validationErrors[] = $limitError;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 해당 상품옵션에만 할인 적용
|
|
$currentSubtotal = $discountedItems[$optionId]['discounted_subtotal']
|
|
?? $discountedItems[$optionId]['subtotal']
|
|
?? 0;
|
|
|
|
// 상품금액 쿠폰 정액할인: 수량만큼 할인
|
|
$targetType = $snapshot['target_type'] ?? $coupon->target_type->value;
|
|
$itemQuantity = ($targetType === CouponTargetType::PRODUCT_AMOUNT->value || $targetType === 'product_amount')
|
|
? ($discountedItems[$optionId]['quantity'] ?? 1)
|
|
: 1;
|
|
|
|
$discount = $this->calculateCouponDiscount($coupon, $currentSubtotal, $snapshot, $itemQuantity);
|
|
|
|
$discountedItems[$optionId]['coupon_discount'] = ($discountedItems[$optionId]['coupon_discount'] ?? 0) + $discount;
|
|
$discountedItems[$optionId]['discounted_subtotal'] = $currentSubtotal - $discount;
|
|
|
|
// 적용 프로모션 기록 (스냅샷 규칙 우선)
|
|
$defaultCurrency = $this->currencyService->getDefaultCurrency();
|
|
$appliedPromotions->addCoupon(new CouponApplication(
|
|
couponId: $coupon->id,
|
|
couponIssueId: $couponIssue->id,
|
|
name: $coupon->getLocalizedName(),
|
|
targetType: $snapshot['target_type'] ?? $coupon->target_type->value,
|
|
targetScope: $snapshot['target_scope'] ?? $coupon->target_scope?->value,
|
|
discountType: $snapshot['discount_type'] ?? $coupon->discount_type->value,
|
|
discountValue: (float) ($snapshot['discount_value'] ?? $coupon->discount_value),
|
|
totalDiscount: $discount,
|
|
totalDiscountFormatted: $this->currencyService->formatPrice($discount, $defaultCurrency),
|
|
minOrderAmount: (int) $coupon->min_order_amount,
|
|
maxDiscountAmount: (int) ($coupon->discount_max_amount ?? 0),
|
|
appliedItems: [
|
|
[
|
|
'product_option_id' => $optionId,
|
|
'discount_amount' => $discount,
|
|
'discount_amount_formatted' => $this->currencyService->formatPrice($discount, $defaultCurrency),
|
|
],
|
|
],
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 기존 로직: 주문 레벨 상품 쿠폰 적용 (target_scope에 따라 적용)
|
|
// is_combinable 검증은 calculate() 에서 전체 슬롯 통합 1회로 이동(A15/MP06).
|
|
if (! empty($coupons)) {
|
|
foreach ($coupons as $couponIssue) {
|
|
$coupon = $couponIssue->coupon;
|
|
$snapshot = $couponSnapshots[$couponIssue->id] ?? null;
|
|
|
|
// 쿠폰 검증 (주문레벨 상품쿠폰: 전체 합계 기준 유지 — override 미전달)
|
|
$error = $this->validateCoupon(
|
|
$couponIssue,
|
|
$itemSubtotals,
|
|
$preparedItems,
|
|
$snapshotMode,
|
|
(int) ($snapshot['min_order_amount'] ?? 0)
|
|
);
|
|
if ($error !== null) {
|
|
$validationErrors[] = $error;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 사용자별 사용 한도 검증 (per_user_limit 축1+축2 — U13b/MP06)
|
|
$limitError = $this->checkPerUserLimit($couponIssue, $input, $snapshotMode, $couponUsageCount);
|
|
if ($limitError !== null) {
|
|
$validationErrors[] = $limitError;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 적용 대상 아이템 필터링
|
|
// 스냅샷 모드: applied_items가 있으면 해당 상품옵션만, 없으면 전체
|
|
if ($snapshotMode) {
|
|
$snapshotAppliedItems = $snapshot['applied_items'] ?? null;
|
|
if ($snapshotAppliedItems !== null) {
|
|
$appliedOptionIds = array_column($snapshotAppliedItems, 'product_option_id');
|
|
$targetItems = array_filter($preparedItems, fn ($item) => in_array($item['product_option_id'], $appliedOptionIds));
|
|
} else {
|
|
$targetItems = $preparedItems;
|
|
}
|
|
} else {
|
|
$targetItems = $this->filterItemsByScope($preparedItems, $coupon);
|
|
}
|
|
if (empty($targetItems)) {
|
|
$validationErrors[] = ValidationError::invalidTarget($coupon->id);
|
|
|
|
continue;
|
|
}
|
|
|
|
// 할인 계산 (1단계: 아이템별 할인 산출)
|
|
$totalDiscount = 0;
|
|
$appliedItems = [];
|
|
|
|
foreach ($targetItems as $item) {
|
|
$optionId = $item['product_option_id'];
|
|
// 원래 소계 기준으로 할인 계산 (병렬 적용)
|
|
$originalSubtotal = $discountedItems[$optionId]['subtotal'] ?? 0;
|
|
|
|
// 상품금액 쿠폰 정액할인: 수량만큼 할인
|
|
$itemQuantity = $discountedItems[$optionId]['quantity'] ?? 1;
|
|
|
|
$discount = $this->calculateCouponDiscount($coupon, $originalSubtotal, $snapshot, $itemQuantity);
|
|
|
|
$totalDiscount += $discount;
|
|
$appliedItems[] = [
|
|
'product_option_id' => $optionId,
|
|
'discount_amount' => $discount,
|
|
];
|
|
}
|
|
|
|
// 2단계: 글로벌 최대 할인금액 제한
|
|
// 복수 아이템 적용 시 per-item cap 통과해도 합계가 글로벌 cap 초과 가능
|
|
$maxDiscountAmount = (int) ($coupon->discount_max_amount ?? 0);
|
|
if ($maxDiscountAmount > 0 && $totalDiscount > $maxDiscountAmount) {
|
|
// 비례 안분: 각 아이템 할인액 비율에 따라 cap을 배분
|
|
$adjustedTotal = 0;
|
|
for ($i = 0; $i < count($appliedItems); $i++) {
|
|
if ($i === count($appliedItems) - 1) {
|
|
// 마지막 아이템: 반올림 오차 보정
|
|
$appliedItems[$i]['discount_amount'] = $maxDiscountAmount - $adjustedTotal;
|
|
} else {
|
|
$appliedItems[$i]['discount_amount'] = (int) floor(
|
|
$maxDiscountAmount * $appliedItems[$i]['discount_amount'] / $totalDiscount
|
|
);
|
|
$adjustedTotal += $appliedItems[$i]['discount_amount'];
|
|
}
|
|
}
|
|
$totalDiscount = $maxDiscountAmount;
|
|
}
|
|
|
|
// 3단계: discountedItems 업데이트 (글로벌 cap 반영 후)
|
|
foreach ($appliedItems as $appliedItem) {
|
|
$optionId = $appliedItem['product_option_id'];
|
|
$discountedItems[$optionId]['coupon_discount'] = ($discountedItems[$optionId]['coupon_discount'] ?? 0) + $appliedItem['discount_amount'];
|
|
$originalSubtotal = $discountedItems[$optionId]['subtotal'] ?? 0;
|
|
$discountedItems[$optionId]['discounted_subtotal'] = $originalSubtotal - ($discountedItems[$optionId]['coupon_discount'] ?? 0);
|
|
}
|
|
|
|
// 적용 프로모션 기록 (스냅샷 규칙 우선)
|
|
$defaultCurrency = $this->currencyService->getDefaultCurrency();
|
|
$appliedPromotions->addCoupon(new CouponApplication(
|
|
couponId: $coupon->id,
|
|
couponIssueId: $couponIssue->id,
|
|
name: $coupon->getLocalizedName(),
|
|
targetType: $snapshot['target_type'] ?? $coupon->target_type->value,
|
|
targetScope: $snapshot['target_scope'] ?? $coupon->target_scope?->value,
|
|
discountType: $snapshot['discount_type'] ?? $coupon->discount_type->value,
|
|
discountValue: (float) ($snapshot['discount_value'] ?? $coupon->discount_value),
|
|
totalDiscount: $totalDiscount,
|
|
totalDiscountFormatted: $this->currencyService->formatPrice($totalDiscount, $defaultCurrency),
|
|
minOrderAmount: (int) $coupon->min_order_amount,
|
|
maxDiscountAmount: (int) ($coupon->discount_max_amount ?? 0),
|
|
appliedItems: array_map(fn ($item) => [
|
|
'product_option_id' => $item['product_option_id'],
|
|
'discount_amount' => $item['discount_amount'],
|
|
'discount_amount_formatted' => $this->currencyService->formatPrice($item['discount_amount'], $defaultCurrency),
|
|
], $appliedItems),
|
|
));
|
|
}
|
|
}
|
|
|
|
return [$discountedItems, $appliedPromotions, $validationErrors];
|
|
}
|
|
|
|
/**
|
|
* 단계 8-a: 옵션별 적립 마일리지를 계산합니다.
|
|
*
|
|
* 적립 마일리지는 주문쿠폰 적용 및 마일리지 사용 후 금액 기준으로 계산됩니다.
|
|
*
|
|
* @param array $preparedItems 준비된 아이템 배열
|
|
* @param array $discountedItems 할인 후 아이템 배열 (주문쿠폰 적용 후)
|
|
* @param array $pointsUsageResult 마일리지 사용 결과 (points_by_option 포함)
|
|
* @param CalculationInput|null $input 계산 입력 (재계산 시 주문 시점 절사 기준 복원용)
|
|
* @return array 옵션별 마일리지 배열 [product_option_id => points]
|
|
*/
|
|
protected function calculatePointsEarning(array $preparedItems, array $discountedItems, array $pointsUsageResult = [], ?CalculationInput $input = null): array
|
|
{
|
|
$pointsPerItem = [];
|
|
|
|
// 적립 절사 기준. 재계산(부분취소·추가결제)은 주문 스냅샷을 우선한다 — 설정을 다시 읽으면
|
|
// 운영자가 이후에 바꾼 절사 기준이 과거 주문에 소급 적용돼, 취소하지 않은 잔여분의
|
|
// 적립액이 취소 처리만으로 달라진다.
|
|
$rounding = $input?->mileagePolicySnapshot !== null
|
|
? MileageRounding::fromOrderSnapshot($input->mileagePolicySnapshot)
|
|
: MileageRounding::normalize($this->resolveMileageCurrencyRule($input));
|
|
|
|
foreach ($preparedItems as $item) {
|
|
$optionId = $item['product_option_id'];
|
|
$option = $item['product_option'];
|
|
|
|
$baseAmount = $discountedItems[$optionId]['discounted_subtotal']
|
|
?? $discountedItems[$optionId]['subtotal']
|
|
?? 0;
|
|
|
|
// 주문쿠폰 안분액 차감
|
|
$orderDiscountShare = $discountedItems[$optionId]['order_discount_share'] ?? 0;
|
|
// 마일리지 사용 안분액 차감
|
|
$pointsUsedShare = $pointsUsageResult['points_by_option'][$optionId] ?? 0;
|
|
|
|
// 마일리지 기능 비활성 시 주문 시점 적립 "계산" 자체를 수행하지 않는다 (전부 0).
|
|
// mileage_value/mileage_type 은 상품 옵션(상품 등록 당시) 속성이므로,
|
|
// 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (정책 확정).
|
|
if (! (bool) $this->settingsService->getSetting('mileage.enabled', false)) {
|
|
$pointsPerItem[$optionId] = 0;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 적립 대상 금액 계산 (상품쿠폰 할인 후 금액 - 주문쿠폰 안분 - 마일리지 안분)
|
|
$earnableAmount = max(0, $baseAmount - $orderDiscountShare - $pointsUsedShare);
|
|
|
|
// 옵션별 마일리지 설정이 있는 경우 활용
|
|
if ($option->mileage_value !== null && $option->mileage_type !== null) {
|
|
if ($option->mileage_type === 'fixed') {
|
|
// 정액 적립: mileage_value를 그대로 사용 (수량 곱)
|
|
$pointsPerItem[$optionId] = MileageRounding::apply($option->mileage_value * $item['quantity'], $rounding);
|
|
} else {
|
|
// 정률 적립: earnableAmount 기준으로 계산
|
|
$pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $option->mileage_value / 100, $rounding);
|
|
}
|
|
} else {
|
|
// 기본 마일리지 적립율 (설정값, 기본 1%)
|
|
$defaultRate = (float) $this->settingsService->getSetting('mileage.default_earn_rate', 1);
|
|
$pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $defaultRate / 100, $rounding);
|
|
}
|
|
}
|
|
|
|
return $pointsPerItem;
|
|
}
|
|
|
|
/**
|
|
* 적립 절사 기준을 담은 통화별 마일리지 규칙을 조회합니다.
|
|
*
|
|
* 마일리지는 표시통화가 아니라 기준통화(base_currency)로 적립·정산되므로, 규칙도
|
|
* 기준통화 기준으로 고른다. 스냅샷 모드에서는 주문 시점 기준통화를, 신규 주문에서는
|
|
* 현재 기본 통화를 쓴다. 해당 통화 규칙이 없으면 첫 행(기본 통화 규칙)으로 폴백한다 —
|
|
* `UserMileageService::currencyRule()` 과 같은 해석 규칙이다.
|
|
*
|
|
* @param CalculationInput|null $input 계산 입력
|
|
* @return array|null 통화별 마일리지 규칙 (없으면 null → 절사 기본값 적용)
|
|
*/
|
|
protected function resolveMileageCurrencyRule(?CalculationInput $input): ?array
|
|
{
|
|
$rules = (array) $this->settingsService->getSetting('mileage.currency_rules', []);
|
|
|
|
if ($rules === []) {
|
|
return null;
|
|
}
|
|
|
|
$currency = $input->metadata['currency_snapshot']['base_currency']
|
|
?? $this->currencyService->getDefaultCurrency();
|
|
|
|
foreach ($rules as $rule) {
|
|
if (is_array($rule) && ($rule['currency_code'] ?? null) === $currency) {
|
|
return $rule;
|
|
}
|
|
}
|
|
|
|
return is_array($rules[0] ?? null) ? $rules[0] : null;
|
|
}
|
|
|
|
/**
|
|
* 단계 2-b: 과세/면세 금액을 분류합니다.
|
|
*
|
|
* @param array $preparedItems 준비된 아이템 배열
|
|
* @param array $discountedItems 할인 후 아이템 배열
|
|
* @return array 옵션별 과세/면세 분류 [product_option_id => [taxable, tax_free]]
|
|
*/
|
|
protected function classifyTaxStatus(array $preparedItems, array $discountedItems): array
|
|
{
|
|
$classification = [];
|
|
|
|
foreach ($preparedItems as $item) {
|
|
$optionId = $item['product_option_id'];
|
|
$product = $item['product'];
|
|
$amount = $discountedItems[$optionId]['discounted_subtotal']
|
|
?? $discountedItems[$optionId]['subtotal']
|
|
?? 0;
|
|
|
|
$isTaxable = $product->tax_status === ProductTaxStatus::TAXABLE;
|
|
|
|
// 세율은 상품별로 다를 수 있다. 스냅샷 모드에서는 주문 시점 세율이 그대로 쓰여
|
|
// 관리자가 이후 상품 세율을 바꿔도 기존 주문의 부가세가 소급 변경되지 않는다.
|
|
$taxRate = $isTaxable
|
|
? (float) ($product->tax_rate ?? VatCalculator::DEFAULT_RATE)
|
|
: null;
|
|
$taxableAmount = $isTaxable ? $amount : 0;
|
|
|
|
$classification[$optionId] = [
|
|
'taxable_amount' => $taxableAmount,
|
|
'tax_free_amount' => $isTaxable ? 0 : $amount,
|
|
'tax_rate' => $taxRate,
|
|
'vat_amount' => VatCalculator::fromTaxableAmount((int) $taxableAmount, $taxRate),
|
|
];
|
|
}
|
|
|
|
return $classification;
|
|
}
|
|
|
|
/**
|
|
* 배송비 할인을 반영한 실질 배송비를 반환합니다.
|
|
*
|
|
* @param array $paymentCalculation 결제금액 계산 결과
|
|
* @return int 할인 후 배송비 (0 이상)
|
|
*/
|
|
protected function resolveNetShippingFee(array $paymentCalculation): int
|
|
{
|
|
$totalShipping = (int) ($paymentCalculation['total_shipping'] ?? 0);
|
|
$shippingDiscount = (int) ($paymentCalculation['shipping_discount'] ?? 0);
|
|
|
|
return max(0, $totalShipping - $shippingDiscount);
|
|
}
|
|
|
|
/**
|
|
* 단계 2-c: 배송비를 과세/면세로 분류합니다.
|
|
*
|
|
* 부가가치세법 제14조는 주된 재화에 부수되는 용역이 주된 재화의 과세/면세를 따른다고 정할 뿐,
|
|
* 과세·면세 상품이 혼합된 장바구니의 배송비 안분에 관한 명문 규정은 없다.
|
|
* 상점의 세무 판단에 따라 3가지 정책 중 선택한다.
|
|
*
|
|
* 상품 분류(classifyTaxStatus)와 달리 이 메서드는 배송비 할인이 확정된 뒤에 호출되어야 한다.
|
|
*
|
|
* @param int $netShippingFee 할인 후 배송비
|
|
* @param int $productTaxable 상품 과세금액 합계
|
|
* @param int $productTaxFree 상품 면세금액 합계
|
|
* @return array{taxable_amount: int, tax_free_amount: int} 배송비 과세/면세 분류
|
|
*/
|
|
protected function classifyShippingFeeTaxStatus(
|
|
int $netShippingFee,
|
|
int $productTaxable,
|
|
int $productTaxFree,
|
|
): array {
|
|
if ($netShippingFee <= 0) {
|
|
return ['taxable_amount' => 0, 'tax_free_amount' => 0];
|
|
}
|
|
|
|
$policy = $this->resolveShippingFeeTaxPolicy();
|
|
$productTotal = $productTaxable + $productTaxFree;
|
|
|
|
// 상품 금액이 전부 0 이면 안분 기준이 없다 — 전액 과세로 처리한다.
|
|
if ($productTotal <= 0) {
|
|
return ['taxable_amount' => $netShippingFee, 'tax_free_amount' => 0];
|
|
}
|
|
|
|
return match ($policy) {
|
|
ShippingFeeTaxPolicy::TAXABLE => [
|
|
'taxable_amount' => $netShippingFee,
|
|
'tax_free_amount' => 0,
|
|
],
|
|
ShippingFeeTaxPolicy::FOLLOW_MAIN_ITEM => $productTaxable >= $productTaxFree
|
|
? ['taxable_amount' => $netShippingFee, 'tax_free_amount' => 0]
|
|
: ['taxable_amount' => 0, 'tax_free_amount' => $netShippingFee],
|
|
ShippingFeeTaxPolicy::PROPORTIONAL => $this->apportionShippingFeeTax(
|
|
$netShippingFee,
|
|
$productTaxable,
|
|
$productTotal,
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 배송비를 과세상품 비율만큼 안분합니다.
|
|
*
|
|
* 면세분을 반올림해 산출한 뒤 잔차를 과세 쪽에 귀속시켜 합계를 보존한다.
|
|
*
|
|
* @param int $netShippingFee 할인 후 배송비
|
|
* @param int $productTaxable 상품 과세금액 합계
|
|
* @param int $productTotal 상품 총액 (과세 + 면세)
|
|
* @return array{taxable_amount: int, tax_free_amount: int} 안분 결과
|
|
*/
|
|
protected function apportionShippingFeeTax(int $netShippingFee, int $productTaxable, int $productTotal): array
|
|
{
|
|
$taxableShipping = (int) round($netShippingFee * $productTaxable / $productTotal);
|
|
$taxableShipping = max(0, min($taxableShipping, $netShippingFee));
|
|
|
|
return [
|
|
'taxable_amount' => $taxableShipping,
|
|
'tax_free_amount' => $netShippingFee - $taxableShipping,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 배송비 과세 정책을 조회합니다.
|
|
*
|
|
* @return ShippingFeeTaxPolicy 배송비 과세 정책
|
|
*/
|
|
protected function resolveShippingFeeTaxPolicy(): ShippingFeeTaxPolicy
|
|
{
|
|
return app(EcommerceSettingsService::class)->getShippingFeeTaxPolicy();
|
|
}
|
|
|
|
/**
|
|
* 단계 3: 배송비를 계산합니다 (정책별 그룹).
|
|
*
|
|
* 스냅샷 모드 시 DB 조회 대신 shippingPolicySnapshots의 정책 데이터를 사용합니다.
|
|
*
|
|
* @param array $preparedItems 준비된 아이템 배열
|
|
* @param array $discountedItems 할인 후 아이템 배열
|
|
* @param ShippingAddress|null $shippingAddress 배송 주소 정보
|
|
* @param CalculationInput|null $input 계산 입력 (스냅샷 모드용)
|
|
* @return array 배송비 계산 결과
|
|
*/
|
|
protected function calculateShippingFee(array $preparedItems, array $discountedItems, ?ShippingAddress $shippingAddress = null, ?CalculationInput $input = null): array
|
|
{
|
|
// 배송정책별로 그룹화
|
|
$policyGroups = $this->groupByShippingPolicy($preparedItems, $discountedItems, $input);
|
|
|
|
$shippingResults = [];
|
|
$baseShippingTotal = 0;
|
|
$extraShippingTotal = 0;
|
|
$zipcode = $shippingAddress?->zipcode;
|
|
$countryCode = $shippingAddress?->countryCode ?? 'KR';
|
|
|
|
foreach ($policyGroups as $policyId => $group) {
|
|
$policy = $group['policy'];
|
|
$groupItems = $group['items'];
|
|
|
|
// 배송정책이 삭제되었거나 조회 불가한 경우 배송비 0으로 처리
|
|
if (! $policy) {
|
|
Log::warning('배송정책을 조회할 수 없어 배송비를 0원으로 처리했습니다', [
|
|
'policy_id' => $policyId,
|
|
'country_code' => $countryCode,
|
|
]);
|
|
|
|
foreach ($groupItems as $item) {
|
|
$shippingResults[$item['product_option_id']] = new AppliedShippingPolicy(
|
|
policyId: $policyId,
|
|
policyName: '',
|
|
countryCode: $countryCode,
|
|
chargePolicy: '',
|
|
policySnapshot: ['country_setting' => null],
|
|
);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// 수신자 국가코드 기준으로 국가별 설정 조회 (스냅샷 모드 시 스냅샷 데이터 사용)
|
|
$countrySetting = $group['_snapshot_country_setting']
|
|
?? $this->resolveCountrySetting($policy, $countryCode);
|
|
|
|
if (! $countrySetting) {
|
|
// 해당 국가 설정이 없으면 배송비 0으로 처리
|
|
Log::warning('국가별 배송 설정이 없어 배송비를 0원으로 처리했습니다', [
|
|
'policy_id' => $policyId,
|
|
'country_code' => $countryCode,
|
|
]);
|
|
|
|
foreach ($groupItems as $item) {
|
|
$shippingResults[$item['product_option_id']] = new AppliedShippingPolicy(
|
|
policyId: $policyId,
|
|
policyName: $policy->getLocalizedName(),
|
|
countryCode: $countryCode,
|
|
chargePolicy: '',
|
|
policySnapshot: ['country_setting' => null],
|
|
);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// 기본 배송비 계산 (국가별 설정 기반)
|
|
$shippingFee = $this->calculateCountryShippingFee($countrySetting, $group);
|
|
$baseShippingTotal += $shippingFee;
|
|
|
|
// 추가 배송비(도서산간) 계산 - KR 전용
|
|
$extraShippingFee = $this->calculateExtraShippingFee($countrySetting, $countryCode, $zipcode, $group);
|
|
$extraShippingTotal += $extraShippingFee;
|
|
|
|
// 배송비를 그룹 내 아이템들에게 안분
|
|
$apportionedShipping = $this->apportionShippingFee($groupItems, $shippingFee);
|
|
$apportionedExtraShipping = $this->apportionShippingFee($groupItems, $extraShippingFee);
|
|
|
|
foreach ($apportionedShipping as $optionId => $shippingInfo) {
|
|
$baseAmount = $shippingInfo['amount'];
|
|
$extraAmount = $apportionedExtraShipping[$optionId]['amount'] ?? 0;
|
|
|
|
// 단독 구매 시 예상 배송비 계산 (해당 아이템만 구매하는 경우)
|
|
$standaloneShippingAmount = $this->calculateStandaloneShippingFee($countrySetting, $countryCode, $groupItems, $optionId, $zipcode);
|
|
|
|
$shippingResults[$optionId] = new AppliedShippingPolicy(
|
|
policyId: $policyId,
|
|
policyName: $policy->getLocalizedName(),
|
|
countryCode: $countryCode,
|
|
chargePolicy: $countrySetting->charge_policy->value,
|
|
shippingAmount: $baseAmount,
|
|
extraShippingAmount: $extraAmount,
|
|
totalShippingAmount: $baseAmount + $extraAmount,
|
|
policySnapshot: [
|
|
'country_code' => $countrySetting->country_code,
|
|
'shipping_method' => $countrySetting->shipping_method,
|
|
'custom_shipping_name' => $countrySetting->custom_shipping_name,
|
|
'currency_code' => $countrySetting->currency_code,
|
|
'charge_policy' => $countrySetting->charge_policy->value,
|
|
'base_fee' => (int) round((float) $countrySetting->base_fee),
|
|
'free_threshold' => $countrySetting->free_threshold ? (int) $countrySetting->free_threshold : null,
|
|
'ranges' => $countrySetting->ranges,
|
|
'extra_fee_enabled' => $countrySetting->extra_fee_enabled,
|
|
'extra_fee_multiply' => $countrySetting->extra_fee_multiply ?? false,
|
|
'extra_fee_settings' => $countrySetting->extra_fee_settings ?? [],
|
|
],
|
|
standaloneShippingAmount: $standaloneShippingAmount,
|
|
hookOverridden: ! empty($group['_hook_overridden']),
|
|
);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'shipping_by_option' => $shippingResults,
|
|
'base_shipping_total' => $baseShippingTotal,
|
|
'extra_shipping_total' => $extraShippingTotal,
|
|
'total_shipping' => $baseShippingTotal + $extraShippingTotal,
|
|
'policy_groups' => $policyGroups,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 추가 배송비(도서산간)를 계산합니다 - KR 전용.
|
|
*
|
|
* @param ShippingPolicyCountrySetting $countrySetting 국가별 설정
|
|
* @param string $countryCode 수신자 국가코드
|
|
* @param string|null $zipcode 우편번호
|
|
* @param array $group 그룹 정보 (total_amount, total_quantity, total_weight, total_volume)
|
|
* @return int 추가 배송비
|
|
*/
|
|
protected function calculateExtraShippingFee(ShippingPolicyCountrySetting $countrySetting, string $countryCode, ?string $zipcode, array $group): int
|
|
{
|
|
// 도서산간 추가배송비는 KR 전용
|
|
if ($countryCode !== 'KR') {
|
|
return 0;
|
|
}
|
|
|
|
if (empty($zipcode)) {
|
|
return 0;
|
|
}
|
|
|
|
// 국가별 설정에서 우편번호 기반 추가배송비 조회
|
|
$extraFee = $countrySetting->getExtraFeeForZipcode($zipcode);
|
|
|
|
if ($extraFee <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
// per_* 정책에서 extra_fee_multiply가 true면 배송 단위 수만큼 중복 부과
|
|
// (기본 배송비와 동일한 단위 수를 사용해야 "단위당" 의미가 어긋나지 않는다)
|
|
if ($countrySetting->extra_fee_multiply && $this->isPerUnitPolicy($countrySetting)) {
|
|
return $extraFee * $this->resolveChargeUnits($countrySetting, $group);
|
|
}
|
|
|
|
// 기본: 1회만 부과
|
|
return $extraFee;
|
|
}
|
|
|
|
/**
|
|
* per_* 부과정책인지 확인합니다.
|
|
*
|
|
* @param ShippingPolicyCountrySetting $countrySetting 국가별 설정
|
|
* @return bool per_* 정책 여부
|
|
*/
|
|
protected function isPerUnitPolicy(ShippingPolicyCountrySetting $countrySetting): bool
|
|
{
|
|
return in_array($countrySetting->charge_policy, ChargePolicyEnum::perUnitPolicies(), true);
|
|
}
|
|
|
|
/**
|
|
* 단독 구매 시 예상 배송비를 계산합니다.
|
|
*
|
|
* 해당 상품옵션만 단독 구매하는 상황을 가정하여 배송비를 계산합니다.
|
|
* UI에서 각 상품별 예상 배송비를 표시하기 위해 사용됩니다.
|
|
*
|
|
* @param ShippingPolicyCountrySetting $countrySetting 국가별 설정
|
|
* @param string $countryCode 수신자 국가코드
|
|
* @param array $groupItems 그룹 내 모든 아이템
|
|
* @param int $targetOptionId 대상 상품옵션 ID
|
|
* @param string|null $zipcode 우편번호 (추가배송비 계산용)
|
|
* @return int 단독 구매 시 예상 배송비
|
|
*/
|
|
protected function calculateStandaloneShippingFee(ShippingPolicyCountrySetting $countrySetting, string $countryCode, array $groupItems, int $targetOptionId, ?string $zipcode): int
|
|
{
|
|
// 대상 아이템 찾기
|
|
$targetItem = null;
|
|
foreach ($groupItems as $item) {
|
|
if ($item['product_option_id'] === $targetOptionId) {
|
|
$targetItem = $item;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (! $targetItem) {
|
|
return 0;
|
|
}
|
|
|
|
// 해당 아이템만의 가상 그룹 생성
|
|
$standaloneGroup = [
|
|
'total_amount' => $targetItem['subtotal'],
|
|
'total_quantity' => $targetItem['quantity'],
|
|
'total_weight' => $targetItem['weight'] ?? 0.0,
|
|
'total_volume' => $targetItem['volume'] ?? 0.0,
|
|
];
|
|
|
|
// 기본 배송비 계산 (단독 구매 기준, 국가별 설정 기반)
|
|
$baseShippingFee = $this->calculateCountryShippingFee($countrySetting, $standaloneGroup);
|
|
|
|
// 추가 배송비 계산 (도서산간 - KR 전용)
|
|
$extraShippingFee = $this->calculateExtraShippingFee($countrySetting, $countryCode, $zipcode, $standaloneGroup);
|
|
|
|
return $baseShippingFee + $extraShippingFee;
|
|
}
|
|
|
|
/**
|
|
* 단계 4: 배송비 할인쿠폰을 적용합니다.
|
|
*
|
|
* @param array $shippingCalculation 배송비 계산 결과
|
|
* @param array $coupons 배송비쿠폰 목록
|
|
* @param CalculationInput|null $input 계산 입력 (스냅샷 모드 메타데이터 포함)
|
|
* @return array [배송비 할인 후 결과, AppliedPromotions, ValidationError[]]
|
|
*/
|
|
protected function applyShippingCoupon(array $shippingCalculation, array $coupons, ?CalculationInput $input = null): array
|
|
{
|
|
$shippingResult = $shippingCalculation;
|
|
$appliedPromotions = new AppliedPromotions;
|
|
$validationErrors = [];
|
|
|
|
if (empty($coupons)) {
|
|
return [$shippingResult, $appliedPromotions, $validationErrors];
|
|
}
|
|
|
|
$snapshotMode = $input->metadata['snapshot_mode'] ?? false;
|
|
$couponSnapshots = $input->metadata['coupon_snapshots'] ?? [];
|
|
|
|
// 할인 대상은 총 배송비 (기본 + 추가)
|
|
$totalShipping = $shippingCalculation['total_shipping'];
|
|
|
|
foreach ($coupons as $couponIssue) {
|
|
$coupon = $couponIssue->coupon;
|
|
$snapshot = $couponSnapshots[$couponIssue->id] ?? null;
|
|
|
|
// 최소 주문금액 검증은 배송비 쿠폰에서는 배송비 기준으로 체크
|
|
$minAmount = $snapshotMode && $snapshot
|
|
? (int) ($snapshot['min_order_amount'] ?? 0)
|
|
: (int) $coupon->min_order_amount;
|
|
|
|
if ($minAmount > 0 && $totalShipping < $minAmount) {
|
|
$validationErrors[] = ValidationError::minAmountNotMet(
|
|
$coupon->id,
|
|
$minAmount,
|
|
$totalShipping
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$discount = $this->calculateCouponDiscount($coupon, $totalShipping, $snapshot);
|
|
|
|
$shippingResult['shipping_discount'] = ($shippingResult['shipping_discount'] ?? 0) + $discount;
|
|
$shippingResult['total_shipping_after_discount'] = $totalShipping - $shippingResult['shipping_discount'];
|
|
|
|
$defaultCurrency = $this->currencyService->getDefaultCurrency();
|
|
$appliedPromotions->addCoupon(new CouponApplication(
|
|
couponId: $coupon->id,
|
|
couponIssueId: $couponIssue->id,
|
|
name: $coupon->getLocalizedName(),
|
|
targetType: $snapshot['target_type'] ?? $coupon->target_type->value,
|
|
discountType: $snapshot['discount_type'] ?? $coupon->discount_type->value,
|
|
discountValue: (float) ($snapshot['discount_value'] ?? $coupon->discount_value),
|
|
totalDiscount: $discount,
|
|
totalDiscountFormatted: $this->currencyService->formatPrice($discount, $defaultCurrency),
|
|
minOrderAmount: (int) $coupon->min_order_amount,
|
|
maxDiscountAmount: (int) ($coupon->discount_max_amount ?? 0),
|
|
));
|
|
|
|
// 배송비 쿠폰은 하나만 적용
|
|
break;
|
|
}
|
|
|
|
return [$shippingResult, $appliedPromotions, $validationErrors];
|
|
}
|
|
|
|
/**
|
|
* 단계 5: 주문금액 할인쿠폰을 적용합니다.
|
|
*
|
|
* @param array $discountedItems 할인 후 아이템 배열
|
|
* @param array $coupons 주문쿠폰 목록
|
|
* @param array $preparedItems 준비된 아이템 배열
|
|
* @param CalculationInput|null $input 계산 입력 (스냅샷 모드 메타데이터 포함)
|
|
* @return array [아이템별 할인 후 금액, AppliedPromotions, ValidationError[]]
|
|
*/
|
|
protected function applyOrderCoupon(array $discountedItems, array $coupons, array $preparedItems, ?CalculationInput $input = null): array
|
|
{
|
|
$appliedPromotions = new AppliedPromotions;
|
|
$validationErrors = [];
|
|
|
|
if (empty($coupons)) {
|
|
return [$discountedItems, $appliedPromotions, $validationErrors];
|
|
}
|
|
|
|
$snapshotMode = $input->metadata['snapshot_mode'] ?? false;
|
|
$couponSnapshots = $input->metadata['coupon_snapshots'] ?? [];
|
|
|
|
// 전체 주문금액 계산
|
|
$totalOrderAmount = 0;
|
|
foreach ($discountedItems as $item) {
|
|
$totalOrderAmount += $item['discounted_subtotal'] ?? $item['subtotal'] ?? 0;
|
|
}
|
|
|
|
foreach ($coupons as $couponIssue) {
|
|
$coupon = $couponIssue->coupon;
|
|
$snapshot = $couponSnapshots[$couponIssue->id] ?? null;
|
|
|
|
// 최소 주문금액 검증 (스냅샷 모드에서는 스냅샷 규칙 사용)
|
|
$minAmount = $snapshotMode && $snapshot
|
|
? (int) ($snapshot['min_order_amount'] ?? 0)
|
|
: (int) $coupon->min_order_amount;
|
|
|
|
if ($minAmount > 0 && $totalOrderAmount < $minAmount) {
|
|
$validationErrors[] = ValidationError::minAmountNotMet(
|
|
$coupon->id,
|
|
$minAmount,
|
|
$totalOrderAmount
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
// 주문 할인 계산 (스냅샷 오버라이드)
|
|
$totalDiscount = $this->calculateCouponDiscount($coupon, $totalOrderAmount, $snapshot);
|
|
|
|
// 각 옵션별로 안분
|
|
$apportioned = $this->apportionAmount($discountedItems, $totalDiscount);
|
|
$appliedItems = [];
|
|
|
|
foreach ($apportioned as $optionId => $share) {
|
|
$discountedItems[$optionId]['order_discount_share'] = ($discountedItems[$optionId]['order_discount_share'] ?? 0) + $share;
|
|
$appliedItems[] = [
|
|
'product_option_id' => $optionId,
|
|
'discount_amount' => $share,
|
|
];
|
|
}
|
|
|
|
$defaultCurrency = $this->currencyService->getDefaultCurrency();
|
|
$appliedPromotions->addCoupon(new CouponApplication(
|
|
couponId: $coupon->id,
|
|
couponIssueId: $couponIssue->id,
|
|
name: $coupon->getLocalizedName(),
|
|
targetType: $snapshot['target_type'] ?? $coupon->target_type->value,
|
|
discountType: $snapshot['discount_type'] ?? $coupon->discount_type->value,
|
|
discountValue: (float) ($snapshot['discount_value'] ?? $coupon->discount_value),
|
|
totalDiscount: $totalDiscount,
|
|
totalDiscountFormatted: $this->currencyService->formatPrice($totalDiscount, $defaultCurrency),
|
|
minOrderAmount: (int) $coupon->min_order_amount,
|
|
maxDiscountAmount: (int) ($coupon->discount_max_amount ?? 0),
|
|
appliedItems: array_map(fn ($item) => [
|
|
'product_option_id' => $item['product_option_id'],
|
|
'discount_amount' => $item['discount_amount'],
|
|
'discount_amount_formatted' => $this->currencyService->formatPrice($item['discount_amount'], $defaultCurrency),
|
|
], $appliedItems),
|
|
));
|
|
|
|
// 주문금액 쿠폰은 하나만 적용
|
|
break;
|
|
}
|
|
|
|
return [$discountedItems, $appliedPromotions, $validationErrors];
|
|
}
|
|
|
|
/**
|
|
* 단계 6: 적립 마일리지 합계를 계산합니다.
|
|
*
|
|
* @param array $pointsPerItem 옵션별 마일리지 배열
|
|
* @return int 마일리지 합계
|
|
*/
|
|
protected function sumPointsEarning(array $pointsPerItem): int
|
|
{
|
|
return array_sum($pointsPerItem);
|
|
}
|
|
|
|
/**
|
|
* 단계 7: 결제금액을 계산합니다.
|
|
*
|
|
* @param array $discountedItems 할인 후 아이템 배열
|
|
* @param array $shippingResult 배송비 결과
|
|
* @return array 결제금액 계산 결과
|
|
*/
|
|
protected function calculatePaymentAmount(array $discountedItems, array $shippingResult): array
|
|
{
|
|
$subtotal = 0;
|
|
$totalCouponDiscount = 0;
|
|
$totalOrderDiscount = 0;
|
|
|
|
foreach ($discountedItems as $item) {
|
|
$subtotal += $item['subtotal'] ?? 0;
|
|
$totalCouponDiscount += $item['coupon_discount'] ?? 0;
|
|
$totalOrderDiscount += $item['order_discount_share'] ?? 0;
|
|
}
|
|
|
|
$baseShippingTotal = $shippingResult['base_shipping_total'] ?? 0;
|
|
$extraShippingTotal = $shippingResult['extra_shipping_total'] ?? 0;
|
|
$totalShipping = $shippingResult['total_shipping'] ?? ($baseShippingTotal + $extraShippingTotal);
|
|
$shippingDiscount = $shippingResult['shipping_discount'] ?? 0;
|
|
|
|
$paymentAmount = $subtotal - $totalCouponDiscount - $totalOrderDiscount + $totalShipping - $shippingDiscount;
|
|
|
|
return [
|
|
'subtotal' => $subtotal,
|
|
'coupon_discount' => $totalCouponDiscount,
|
|
'order_discount' => $totalOrderDiscount,
|
|
'base_shipping_total' => $baseShippingTotal,
|
|
'extra_shipping_total' => $extraShippingTotal,
|
|
'total_shipping' => $totalShipping,
|
|
'shipping_discount' => $shippingDiscount,
|
|
'payment_amount' => max(0, $paymentAmount),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 단계 8: 마일리지 사용을 적용합니다.
|
|
*
|
|
* @param int $paymentAmount 결제금액
|
|
* @param int $usePoints 사용할 마일리지
|
|
* @param array $discountedItems 할인 후 아이템 배열
|
|
* @return array 마일리지 사용 결과
|
|
*/
|
|
protected function applyPointsUsage(int $paymentAmount, int $usePoints, array $discountedItems): array
|
|
{
|
|
// 사용 가능한 마일리지는 결제금액을 초과할 수 없음
|
|
$actualPoints = min($usePoints, $paymentAmount);
|
|
|
|
// 마일리지를 각 옵션별로 안분
|
|
$apportioned = $this->apportionAmount($discountedItems, $actualPoints);
|
|
|
|
return [
|
|
'requested_points' => $usePoints,
|
|
'actual_points' => $actualPoints,
|
|
'points_by_option' => $apportioned,
|
|
'final_amount' => max(0, $paymentAmount - $actualPoints),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 최종 결과를 빌드합니다.
|
|
*
|
|
* @param CalculationInput $input 계산 입력 데이터 (paymentCurrency 접근용)
|
|
*/
|
|
protected function buildFinalResult(
|
|
array $preparedItems,
|
|
array $itemSubtotals,
|
|
array $itemsAfterProductDiscount,
|
|
array $itemsAfterOrderDiscount,
|
|
array $pointsPerItem,
|
|
array $taxClassification,
|
|
array $shippingResult,
|
|
array $pointsUsageResult,
|
|
array $paymentCalculation,
|
|
AppliedPromotions $productPromotions,
|
|
AppliedPromotions $orderPromotions,
|
|
AppliedPromotions $shippingPromotions,
|
|
int $totalPointsEarning,
|
|
array $validationErrors,
|
|
CalculationInput $input
|
|
): OrderCalculationResult {
|
|
$items = [];
|
|
|
|
// 각 옵션별 적용된 쿠폰 매핑 생성
|
|
$promotionsByOption = $this->buildPromotionsByOption($productPromotions);
|
|
|
|
foreach ($preparedItems as $item) {
|
|
$optionId = $item['product_option_id'];
|
|
|
|
$itemCalc = new ItemCalculation(
|
|
productId: $item['product_id'],
|
|
productOptionId: $optionId,
|
|
quantity: $item['quantity'],
|
|
unitPrice: $item['unit_price'],
|
|
additionalOptionsTotal: (int) ($item['additional_options_total'] ?? 0),
|
|
subtotal: $itemSubtotals[$optionId]['subtotal'] ?? 0,
|
|
productCouponDiscountAmount: $itemsAfterProductDiscount[$optionId]['coupon_discount'] ?? 0,
|
|
codeDiscountAmount: 0,
|
|
orderCouponDiscountShare: $itemsAfterOrderDiscount[$optionId]['order_discount_share'] ?? 0,
|
|
pointsUsedShare: $pointsUsageResult['points_by_option'][$optionId] ?? 0,
|
|
pointsEarning: $pointsPerItem[$optionId] ?? 0,
|
|
taxableAmount: $taxClassification[$optionId]['taxable_amount'] ?? 0,
|
|
taxFreeAmount: $taxClassification[$optionId]['tax_free_amount'] ?? 0,
|
|
finalAmount: $this->calculateItemFinalAmount($optionId, $itemsAfterOrderDiscount, $pointsUsageResult),
|
|
appliedShippingPolicy: $shippingResult['shipping_by_option'][$optionId] ?? null,
|
|
appliedPromotions: $promotionsByOption[$optionId] ?? null,
|
|
productName: $item['product']->getLocalizedName(),
|
|
optionName: $item['product_option']->getLocalizedOptionName(),
|
|
additionalOptionsSnapshot: $item['additional_options_snapshot'] ?? [],
|
|
);
|
|
|
|
$items[] = $itemCalc;
|
|
}
|
|
|
|
// Summary 계산
|
|
$totalTaxable = 0;
|
|
$totalTaxFree = 0;
|
|
foreach ($taxClassification as $tax) {
|
|
$totalTaxable += $tax['taxable_amount'];
|
|
$totalTaxFree += $tax['tax_free_amount'];
|
|
}
|
|
|
|
// 부가세는 옵션별 부가세의 합 — 세율이 섞인 주문에서는 이것만이 정확하다
|
|
// (전체 과세표준에 단일 세율을 적용하면 면세·영세·다른 세율 상품이 섞였을 때 어긋난다)
|
|
$totalVat = VatCalculator::sumFromClassification($taxClassification);
|
|
|
|
// 배송비 과세 분류 — 상품 분류(단계 2-b)와 달리 할인 후 최종 배송비가 확정된 이 시점에서만
|
|
// 계산할 수 있다. 옵션별 taxable_amount 는 상품분만 담으므로 배송비는 Summary 에만 합산한다.
|
|
$shippingTax = $this->classifyShippingFeeTaxStatus(
|
|
$this->resolveNetShippingFee($paymentCalculation),
|
|
$totalTaxable,
|
|
$totalTaxFree,
|
|
);
|
|
$totalTaxable += $shippingTax['taxable_amount'];
|
|
$totalTaxFree += $shippingTax['tax_free_amount'];
|
|
|
|
// 배송비 과세분에 내재된 부가세도 합산 — 배송비는 옵션별 세율이 없으므로 기본 세율을 적용한다
|
|
// (과세표준(taxableAmount)에 배송비가 포함되므로 부가세도 같은 기준을 따라야 정합)
|
|
$totalVat += VatCalculator::fromTaxableAmount($shippingTax['taxable_amount']);
|
|
|
|
$summary = new Summary(
|
|
subtotal: $paymentCalculation['subtotal'],
|
|
productCouponDiscount: $paymentCalculation['coupon_discount'],
|
|
codeDiscount: 0,
|
|
orderCouponDiscount: $paymentCalculation['order_discount'],
|
|
totalDiscount: $paymentCalculation['coupon_discount'] + $paymentCalculation['order_discount'],
|
|
baseShippingTotal: $paymentCalculation['base_shipping_total'],
|
|
extraShippingTotal: $paymentCalculation['extra_shipping_total'],
|
|
totalShipping: $paymentCalculation['total_shipping'],
|
|
shippingDiscount: $paymentCalculation['shipping_discount'],
|
|
taxableAmount: $totalTaxable,
|
|
taxFreeAmount: $totalTaxFree,
|
|
vatAmount: $totalVat,
|
|
pointsEarning: $totalPointsEarning,
|
|
pointsUsed: $pointsUsageResult['actual_points'],
|
|
paymentAmount: $paymentCalculation['payment_amount'],
|
|
finalAmount: $pointsUsageResult['final_amount'],
|
|
);
|
|
|
|
// 다통화 변환 적용
|
|
foreach ($items as $itemCalc) {
|
|
$itemCalc->multiCurrency = $this->buildItemMultiCurrency($itemCalc);
|
|
}
|
|
$summary->multiCurrency = $this->buildSummaryMultiCurrency($summary);
|
|
|
|
// 결제 통화 선택
|
|
if ($input->paymentCurrency !== null) {
|
|
$summary->selectedPaymentCurrency = $input->paymentCurrency;
|
|
}
|
|
|
|
// 프로모션 요약
|
|
$promotionsSummary = new PromotionsSummary(
|
|
productPromotions: $productPromotions,
|
|
orderPromotions: new AppliedPromotions(
|
|
coupons: array_merge($orderPromotions->coupons, $shippingPromotions->coupons),
|
|
),
|
|
);
|
|
|
|
return new OrderCalculationResult(
|
|
items: $items,
|
|
summary: $summary,
|
|
promotions: $promotionsSummary,
|
|
validationErrors: $validationErrors,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 아이템의 최종 금액을 계산합니다.
|
|
*/
|
|
protected function calculateItemFinalAmount(int $optionId, array $discountedItems, array $pointsUsageResult): int
|
|
{
|
|
$item = $discountedItems[$optionId] ?? [];
|
|
$subtotal = $item['subtotal'] ?? 0;
|
|
$couponDiscount = $item['coupon_discount'] ?? 0;
|
|
$orderDiscount = $item['order_discount_share'] ?? 0;
|
|
$pointsUsed = $pointsUsageResult['points_by_option'][$optionId] ?? 0;
|
|
|
|
return max(0, $subtotal - $couponDiscount - $orderDiscount - $pointsUsed);
|
|
}
|
|
|
|
/**
|
|
* 아이템의 다통화 변환 금액을 생성합니다.
|
|
*
|
|
* @param ItemCalculation $item 아이템 계산 결과
|
|
* @return MultiCurrencyPrices 다통화 변환 금액
|
|
*/
|
|
protected function buildItemMultiCurrency(ItemCalculation $item): MultiCurrencyPrices
|
|
{
|
|
$amounts = [
|
|
'unit_price' => $item->unitPrice,
|
|
'additional_options_total' => $item->additionalOptionsTotal,
|
|
'subtotal' => $item->subtotal,
|
|
'product_coupon_discount_amount' => $item->productCouponDiscountAmount,
|
|
'code_discount_amount' => $item->codeDiscountAmount,
|
|
'order_coupon_discount_share' => $item->orderCouponDiscountShare,
|
|
'points_used_share' => $item->pointsUsedShare,
|
|
'taxable_amount' => $item->taxableAmount,
|
|
'tax_free_amount' => $item->taxFreeAmount,
|
|
'final_amount' => $item->finalAmount,
|
|
];
|
|
|
|
return new MultiCurrencyPrices(
|
|
currencies: $this->currencyService->convertMultipleAmounts($amounts)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Summary의 다통화 변환 금액을 생성합니다.
|
|
*
|
|
* @param Summary $summary 합계 정보
|
|
* @return MultiCurrencyPrices 다통화 변환 금액
|
|
*/
|
|
protected function buildSummaryMultiCurrency(Summary $summary): MultiCurrencyPrices
|
|
{
|
|
$amounts = [
|
|
'subtotal' => $summary->subtotal,
|
|
'product_coupon_discount' => $summary->productCouponDiscount,
|
|
'code_discount' => $summary->codeDiscount,
|
|
'order_coupon_discount' => $summary->orderCouponDiscount,
|
|
'total_discount' => $summary->totalDiscount,
|
|
'base_shipping_total' => $summary->baseShippingTotal,
|
|
'extra_shipping_total' => $summary->extraShippingTotal,
|
|
'total_shipping' => $summary->totalShipping,
|
|
'shipping_discount' => $summary->shippingDiscount,
|
|
'taxable_amount' => $summary->taxableAmount,
|
|
'tax_free_amount' => $summary->taxFreeAmount,
|
|
'payment_amount' => $summary->paymentAmount,
|
|
'final_amount' => $summary->finalAmount,
|
|
];
|
|
|
|
return new MultiCurrencyPrices(
|
|
currencies: $this->currencyService->convertMultipleAmounts($amounts)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 쿠폰을 검증합니다.
|
|
*
|
|
* 스냅샷 모드에서는 만료/유효기간 검증을 스킵하고, 스냅샷의 min_order_amount를 사용합니다.
|
|
*
|
|
* @param CouponIssue $couponIssue 쿠폰 발급 정보
|
|
* @param array $itemSubtotals 아이템 소계 배열
|
|
* @param array $preparedItems 준비된 아이템 배열
|
|
* @param bool $snapshotMode 스냅샷 모드 여부
|
|
* @param int $snapshotMinOrderAmount 스냅샷의 최소 주문금액
|
|
*/
|
|
protected function validateCoupon(
|
|
CouponIssue $couponIssue,
|
|
array $itemSubtotals,
|
|
array $preparedItems,
|
|
bool $snapshotMode = false,
|
|
int $snapshotMinOrderAmount = 0,
|
|
?int $applicableAmountOverride = null
|
|
): ?ValidationError {
|
|
$coupon = $couponIssue->coupon;
|
|
|
|
// 스냅샷 모드: 만료/유효기간 검증 스킵 (이미 적용 완료된 쿠폰)
|
|
if (! $snapshotMode) {
|
|
// 만료 여부 검증 (CouponIssue의 expired_at 포함)
|
|
if ($couponIssue->isExpired()) {
|
|
return ValidationError::couponExpired($coupon->id);
|
|
}
|
|
|
|
// 유효기간 검증 (valid_from, valid_to)
|
|
$now = now();
|
|
if ($couponIssue->valid_from && $now->lt($couponIssue->valid_from)) {
|
|
return ValidationError::couponExpired($coupon->id);
|
|
}
|
|
if ($couponIssue->valid_to && $now->gt($couponIssue->valid_to)) {
|
|
return ValidationError::couponExpired($coupon->id);
|
|
}
|
|
|
|
// 이미 사용된 쿠폰 검증
|
|
if ($couponIssue->used_at !== null) {
|
|
return ValidationError::alreadyUsed($coupon->id);
|
|
}
|
|
}
|
|
|
|
// 최소 주문금액 검증 (스냅샷 모드: 스냅샷 값 사용)
|
|
// 상품 쿠폰(itemCoupons, optionId별 1:1 적용)은 적용 대상 옵션 소계 기준으로 검증한다
|
|
// ($applicableAmountOverride). null 이면 기존 전체 합계 기준(주문/배송비 쿠폰 — U13b/MP06).
|
|
$minAmount = $snapshotMode ? $snapshotMinOrderAmount : (int) $coupon->min_order_amount;
|
|
if ($minAmount > 0) {
|
|
$amount = $applicableAmountOverride
|
|
?? array_sum(array_column($itemSubtotals, 'subtotal'));
|
|
if ($amount < $minAmount) {
|
|
return ValidationError::minAmountNotMet(
|
|
$coupon->id,
|
|
$minAmount,
|
|
$amount
|
|
);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 쿠폰 조합 가능 여부를 검증합니다. (is_combinable)
|
|
*
|
|
* @param CouponIssue[] $coupons 쿠폰 발급 목록
|
|
* @return ValidationError[] 검증 오류 목록
|
|
*/
|
|
protected function validateCouponCombination(array $coupons): array
|
|
{
|
|
$errors = [];
|
|
foreach ($this->findNonCombinableViolations($coupons) as $couponIssue) {
|
|
$errors[] = ValidationError::notCombinable($couponIssue->coupon->id);
|
|
}
|
|
|
|
return $errors;
|
|
}
|
|
|
|
/**
|
|
* 조합 불가(is_combinable=false) 위반 쿠폰 발급건을 찾습니다. (A15/MP06)
|
|
*
|
|
* 적용 전체 쿠폰이 2개 이상이고 그중 is_combinable=false 쿠폰이 있으면, 그 false 쿠폰들이
|
|
* 위반 대상이다(다른 어떤 쿠폰과도 함께 적용 불가). 쿠폰이 1개뿐이면 위반 없음.
|
|
*
|
|
* @param CouponIssue[] $coupons 적용 대상 전체 쿠폰(슬롯 통합)
|
|
* @return CouponIssue[] issueId 기준 중복 제거된 위반 쿠폰 발급건
|
|
*/
|
|
protected function findNonCombinableViolations(array $coupons): array
|
|
{
|
|
// 동일 발급 ID 중복 제거 (슬롯 교차 시 같은 issueId 가 양쪽에 등장 가능 — A15/MP06)
|
|
$unique = [];
|
|
foreach ($coupons as $couponIssue) {
|
|
$unique[$couponIssue->id] = $couponIssue;
|
|
}
|
|
|
|
if (count($unique) <= 1) {
|
|
return [];
|
|
}
|
|
|
|
$violations = [];
|
|
foreach ($unique as $couponIssue) {
|
|
if (! $couponIssue->coupon->is_combinable) {
|
|
$violations[$couponIssue->id] = $couponIssue;
|
|
}
|
|
}
|
|
|
|
return array_values($violations);
|
|
}
|
|
|
|
/**
|
|
* 사용자별 쿠폰 사용 한도(per_user_limit)를 검증합니다. (U13b/MP06)
|
|
*
|
|
* 축1=과거 사용(used_at IS NOT NULL, 동일 사용자·쿠폰) + 축2=주문 내 동일 쿠폰 다중 라인.
|
|
* 두 축 합 + 이번 적용 1건이 한도를 초과하면 검증 오류를 반환한다.
|
|
* 스냅샷 모드(환불 재계산)는 이미 확정된 쿠폰이므로 검증하지 않는다.
|
|
* per_user_limit = 0 은 무제한이다.
|
|
*
|
|
* @param CouponIssue $couponIssue 쿠폰 발급 내역
|
|
* @param CalculationInput $input 계산 입력 (userId 보유)
|
|
* @param bool $snapshotMode 스냅샷(환불 재계산) 모드 여부
|
|
* @param array<int, int> $couponUsageCount coupon_id => 이번 주문 적용 누적 (참조 갱신)
|
|
* @return ValidationError|null 한도 초과 시 오류, 통과 시 null
|
|
*/
|
|
protected function checkPerUserLimit(
|
|
CouponIssue $couponIssue,
|
|
CalculationInput $input,
|
|
bool $snapshotMode,
|
|
array &$couponUsageCount
|
|
): ?ValidationError {
|
|
$coupon = $couponIssue->coupon;
|
|
$limit = (int) $coupon->per_user_limit;
|
|
|
|
// 스냅샷 모드 또는 무제한(0)이면 검증 없이 누적만 갱신
|
|
if ($snapshotMode || $limit <= 0) {
|
|
$couponUsageCount[$coupon->id] = ($couponUsageCount[$coupon->id] ?? 0) + 1;
|
|
|
|
return null;
|
|
}
|
|
|
|
// 축1: 과거 사용 완료 건수 (회원만 — 비회원은 0)
|
|
$priorUsed = ($input->userId !== null)
|
|
? $this->couponIssueRepository->getUserUsedCountForCoupon($input->userId, $coupon->id)
|
|
: 0;
|
|
|
|
// 축2: 이번 주문 내 동일 쿠폰 적용 누적
|
|
$current = $couponUsageCount[$coupon->id] ?? 0;
|
|
|
|
if ($priorUsed + $current + 1 > $limit) {
|
|
return ValidationError::perUserLimitExceeded(
|
|
$coupon->id,
|
|
$limit,
|
|
$priorUsed + $current
|
|
);
|
|
}
|
|
|
|
$couponUsageCount[$coupon->id] = $current + 1;
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 조합 검증 대상 전체 쿠폰을 수집합니다. (4개 슬롯 통합, 중복 발급건 제거)
|
|
*
|
|
* @param CouponIssue[] $coupons 주문/배송/상품(PRODUCT_AMOUNT) 슬롯 쿠폰
|
|
* @param CouponIssue[] $itemCouponIssues 상품별(itemCoupons) 슬롯 쿠폰
|
|
* @return CouponIssue[] issueId 기준 중복 제거된 전체 적용 쿠폰
|
|
*/
|
|
protected function collectAllCouponsForCombination(array $coupons, array $itemCouponIssues): array
|
|
{
|
|
$merged = array_merge(array_values($coupons), array_values($itemCouponIssues));
|
|
$byId = [];
|
|
foreach ($merged as $issue) {
|
|
$byId[$issue->id] = $issue;
|
|
}
|
|
|
|
return array_values($byId);
|
|
}
|
|
|
|
/**
|
|
* 쿠폰 할인액을 계산합니다.
|
|
*
|
|
* 스냅샷 모드에서는 쿠폰 규칙(할인유형/할인값)을 스냅샷 값으로 오버라이드합니다.
|
|
*
|
|
* @param Coupon $coupon 쿠폰
|
|
* @param int $amount 적용 대상 금액
|
|
* @param array|null $snapshotOverride 스냅샷 오버라이드 [discount_type, discount_value, max_discount_amount]
|
|
* @return int 할인액
|
|
*/
|
|
protected function calculateCouponDiscount(Coupon $coupon, int $amount, ?array $snapshotOverride = null, int $quantity = 1): int
|
|
{
|
|
$discountType = $snapshotOverride['discount_type'] ?? $coupon->discount_type->value;
|
|
$discountValue = (float) ($snapshotOverride['discount_value'] ?? $coupon->discount_value);
|
|
|
|
if ($discountType === CouponDiscountType::FIXED->value || $discountType === 'fixed') {
|
|
// 정액 할인: 수량만큼 할인 (상품금액 쿠폰에서 수량 전달)
|
|
return min((int) $discountValue * $quantity, $amount);
|
|
}
|
|
|
|
// 정률 할인
|
|
$discount = (int) floor($amount * $discountValue / 100);
|
|
|
|
// 최대 할인금액 제한 (스냅샷 우선, 없으면 라이브 쿠폰 값)
|
|
$maxDiscountAmount = (int) ($snapshotOverride['max_discount_amount'] ?? $coupon->discount_max_amount ?? 0);
|
|
if ($maxDiscountAmount > 0) {
|
|
$discount = min($discount, $maxDiscountAmount);
|
|
}
|
|
|
|
return min($discount, $amount);
|
|
}
|
|
|
|
/**
|
|
* 아이템을 쿠폰 적용 범위로 필터링합니다.
|
|
*
|
|
* @param array $preparedItems 준비된 아이템 배열
|
|
* @param Coupon $coupon 쿠폰
|
|
* @return array 필터링된 아이템 배열
|
|
*/
|
|
protected function filterItemsByScope(array $preparedItems, Coupon $coupon): array
|
|
{
|
|
if ($coupon->target_scope === CouponTargetScope::ALL) {
|
|
return $preparedItems;
|
|
}
|
|
|
|
$includedProductIds = $coupon->includedProducts->pluck('id')->toArray();
|
|
$excludedProductIds = $coupon->excludedProducts->pluck('id')->toArray();
|
|
$includedCategoryIds = $coupon->includedCategories->pluck('id')->toArray();
|
|
$excludedCategoryIds = $coupon->excludedCategories->pluck('id')->toArray();
|
|
|
|
return array_filter($preparedItems, function ($item) use (
|
|
$coupon,
|
|
$includedProductIds,
|
|
$excludedProductIds,
|
|
$includedCategoryIds,
|
|
$excludedCategoryIds
|
|
) {
|
|
$productId = $item['product_id'];
|
|
$product = $item['product'];
|
|
$categoryIds = $product->categories->pluck('id')->toArray();
|
|
|
|
// 제외 상품/카테고리 체크
|
|
if (in_array($productId, $excludedProductIds)) {
|
|
return false;
|
|
}
|
|
if (! empty(array_intersect($categoryIds, $excludedCategoryIds))) {
|
|
return false;
|
|
}
|
|
|
|
// 포함 조건 체크
|
|
if ($coupon->target_scope === CouponTargetScope::PRODUCTS) {
|
|
return in_array($productId, $includedProductIds);
|
|
}
|
|
|
|
if ($coupon->target_scope === CouponTargetScope::CATEGORIES) {
|
|
return ! empty(array_intersect($categoryIds, $includedCategoryIds));
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 아이템을 배송정책별로 그룹화합니다.
|
|
*
|
|
* 스냅샷 모드 시 shippingPolicySnapshots에서 정책 데이터를 가져옵니다.
|
|
*
|
|
* @param array $preparedItems 준비된 아이템 배열
|
|
* @param array $discountedItems 할인 후 아이템 배열
|
|
* @param CalculationInput|null $input 계산 입력 (스냅샷 모드용)
|
|
* @return array 배송정책별 그룹
|
|
*/
|
|
protected function groupByShippingPolicy(array $preparedItems, array $discountedItems, ?CalculationInput $input = null): array
|
|
{
|
|
$groups = [];
|
|
$shippingPolicySnapshots = $input?->shippingPolicySnapshots ?? [];
|
|
|
|
foreach ($preparedItems as $item) {
|
|
$product = $item['product'];
|
|
$isSnapshotMode = ! empty($item['_snapshot_mode']);
|
|
$policyId = $product->shipping_policy_id;
|
|
|
|
// 통상 모드에서 상품에 정책이 없으면(shipping_policy_id=null) 기본 배송정책으로 폴백한다.
|
|
// 폴백된 기본정책 ID 로 그룹화되어, 주문 생성 시 그 기본정책 전체가 스냅샷에 동결되고
|
|
// (delivery_policy_snapshot) 환불 재계산은 동결된 스냅샷 기준으로 동작한다.
|
|
// 스냅샷 모드는 주문 시점 동결된 policy_id(폴백 결과 포함)를 그대로 사용한다.
|
|
$fallbackPolicy = null;
|
|
if (! $policyId && ! $isSnapshotMode) {
|
|
$fallbackPolicy = $this->shippingPolicyResolver->getDefaultPolicy();
|
|
$policyId = $fallbackPolicy?->id;
|
|
}
|
|
|
|
if (! $policyId) {
|
|
continue;
|
|
}
|
|
|
|
$optionId = $item['product_option_id'];
|
|
|
|
if (! isset($groups[$policyId])) {
|
|
$policy = null;
|
|
$snapshotCountrySetting = null;
|
|
|
|
if ($isSnapshotMode && isset($shippingPolicySnapshots[$optionId])) {
|
|
// 스냅샷 모드: DB 조회 없이 스냅샷의 정책 데이터 사용
|
|
$policySnapshotData = $shippingPolicySnapshots[$optionId];
|
|
$policy = $this->buildSnapshotShippingPolicy($policyId, $policySnapshotData);
|
|
$snapshotCountrySetting = $this->buildSnapshotCountrySetting($policySnapshotData);
|
|
} elseif ($fallbackPolicy !== null) {
|
|
// 통상 모드 + 기본정책 폴백: 해석된 기본 배송정책 사용
|
|
$policy = $fallbackPolicy;
|
|
if (! $policy->relationLoaded('countrySettings')) {
|
|
$policy->load('countrySettings');
|
|
}
|
|
} else {
|
|
// 통상 모드: DB에서 배송정책 로드
|
|
$policy = $product->shippingPolicy;
|
|
if ($policy && ! $policy->relationLoaded('countrySettings')) {
|
|
$policy->load('countrySettings');
|
|
}
|
|
$policy = $policy ?? $this->shippingPolicyRepository->find($policyId);
|
|
}
|
|
|
|
$groups[$policyId] = [
|
|
'policy' => $policy,
|
|
'items' => [],
|
|
'total_amount' => 0,
|
|
'total_quantity' => 0,
|
|
'total_weight' => 0.0,
|
|
'total_volume' => 0.0,
|
|
'_snapshot_country_setting' => $snapshotCountrySetting,
|
|
];
|
|
} elseif ($isSnapshotMode && $groups[$policyId]['_snapshot_country_setting'] === null && isset($shippingPolicySnapshots[$optionId])) {
|
|
// 같은 정책 그룹에 이미 들어갔지만 스냅샷 국가설정이 아직 없는 경우
|
|
$groups[$policyId]['_snapshot_country_setting'] = $this->buildSnapshotCountrySetting($shippingPolicySnapshots[$optionId]);
|
|
}
|
|
|
|
$amount = $discountedItems[$optionId]['discounted_subtotal']
|
|
?? $discountedItems[$optionId]['subtotal']
|
|
?? 0;
|
|
|
|
$productOption = $item['product_option'];
|
|
$itemWeight = (float) ($productOption->weight ?? 0) * $item['quantity'];
|
|
$itemVolume = (float) ($productOption->volume ?? 0) * $item['quantity'];
|
|
|
|
$groups[$policyId]['items'][] = [
|
|
'product_option_id' => $optionId,
|
|
'subtotal' => $amount,
|
|
'quantity' => $item['quantity'],
|
|
'weight' => $itemWeight,
|
|
'volume' => $itemVolume,
|
|
];
|
|
$groups[$policyId]['total_amount'] += $amount;
|
|
$groups[$policyId]['total_quantity'] += $item['quantity'];
|
|
$groups[$policyId]['total_weight'] += $itemWeight;
|
|
$groups[$policyId]['total_volume'] += $itemVolume;
|
|
|
|
// 스냅샷 모드 + 훅 오버라이드 배송비: 원 주문 시 훅이 계산한 배송비를 그룹에 누적
|
|
// → 훅 비활성 시에도 스냅샷 기반 환불 정확성 보장 (내장 정책은 재계산으로 처리)
|
|
if ($isSnapshotMode
|
|
&& ! empty($shippingPolicySnapshots[$optionId]['hook_overridden'])
|
|
&& isset($shippingPolicySnapshots[$optionId]['shipping_amount'])
|
|
) {
|
|
$groups[$policyId]['_snapshot_shipping_fee'] = ($groups[$policyId]['_snapshot_shipping_fee'] ?? 0)
|
|
+ (int) $shippingPolicySnapshots[$optionId]['shipping_amount'];
|
|
}
|
|
}
|
|
|
|
return $groups;
|
|
}
|
|
|
|
/**
|
|
* 배송정책의 국가별 설정을 조회합니다.
|
|
*
|
|
* @param ShippingPolicy $policy 배송정책
|
|
* @param string $countryCode 국가코드
|
|
* @return ShippingPolicyCountrySetting|null 국가별 설정
|
|
*/
|
|
protected function resolveCountrySetting(ShippingPolicy $policy, string $countryCode): ?ShippingPolicyCountrySetting
|
|
{
|
|
$settings = $policy->relationLoaded('countrySettings')
|
|
? $policy->countrySettings
|
|
: $policy->countrySettings()->get();
|
|
|
|
return $settings
|
|
->where('country_code', $countryCode)
|
|
->where('is_active', true)
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* 국가별 설정에 따라 배송비를 계산합니다.
|
|
*
|
|
* @param ShippingPolicyCountrySetting $countrySetting 국가별 설정
|
|
* @param array $group 그룹 정보 (total_amount, total_quantity, total_weight, total_volume)
|
|
* @return int 배송비
|
|
*/
|
|
protected function calculateCountryShippingFee(ShippingPolicyCountrySetting $countrySetting, array &$group): int
|
|
{
|
|
// 스냅샷 모드: 원 주문 시 계산된 배송비를 직접 사용 (플러그인 훅 미호출)
|
|
// → 플러그인 ON/OFF 무관하게 환불 정확성 보장
|
|
if (isset($group['_snapshot_shipping_fee'])) {
|
|
return (int) $group['_snapshot_shipping_fee'];
|
|
}
|
|
|
|
// 플러그인 우선 계산 (비스냅샷 모드: 주문 생성, 가격 미리보기 등)
|
|
// 플러그인이 non-null을 반환하면 내장 match 로직을 건너뜀
|
|
$pluginFee = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.shipping.calculate_fee',
|
|
null,
|
|
$countrySetting,
|
|
$group
|
|
);
|
|
if ($pluginFee !== null) {
|
|
$group['_hook_overridden'] = true;
|
|
|
|
return (int) $pluginFee;
|
|
}
|
|
|
|
// 내장 정책 계산 (플러그인 미개입 시)
|
|
$groupTotal = $group['total_amount'];
|
|
$groupQuantity = $group['total_quantity'];
|
|
$groupWeight = $group['total_weight'] ?? 0.0;
|
|
$groupVolume = $group['total_volume'] ?? 0.0;
|
|
|
|
// 단위 환산은 이 지점 한 곳에서만 수행한다.
|
|
// 상품 옵션은 g / cm³ 로 저장되고, 배송정책 구간·단위값은 kg / L 로 입력받는다.
|
|
$volumeWeightDivisor = $countrySetting->ranges['volume_weight_divisor'] ?? 6000;
|
|
$weightKg = $groupWeight / 1000;
|
|
$volumeLiter = $groupVolume / 1000;
|
|
// 부피무게(kg) = 부피(cm³) / 부피무게 계수 (기본 6000)
|
|
$volumetricWeightKg = $volumeWeightDivisor > 0 ? $groupVolume / $volumeWeightDivisor : 0.0;
|
|
$chargeableWeightKg = max($weightKg, $volumetricWeightKg);
|
|
|
|
return match ($countrySetting->charge_policy) {
|
|
ChargePolicyEnum::FREE => 0,
|
|
ChargePolicyEnum::FIXED => (int) round((float) $countrySetting->base_fee),
|
|
ChargePolicyEnum::CONDITIONAL_FREE => $groupTotal >= $countrySetting->free_threshold ? 0 : (int) round((float) $countrySetting->base_fee),
|
|
ChargePolicyEnum::RANGE_AMOUNT => $this->calculateRangeFee($countrySetting->ranges, $groupTotal, $countrySetting),
|
|
ChargePolicyEnum::RANGE_QUANTITY => $this->calculateRangeFee($countrySetting->ranges, $groupQuantity, $countrySetting),
|
|
ChargePolicyEnum::RANGE_WEIGHT => $this->calculateRangeFee($countrySetting->ranges, $weightKg, $countrySetting),
|
|
ChargePolicyEnum::RANGE_VOLUME => $this->calculateRangeFee($countrySetting->ranges, $volumeLiter, $countrySetting),
|
|
ChargePolicyEnum::RANGE_VOLUME_WEIGHT => $this->calculateRangeFee($countrySetting->ranges, $chargeableWeightKg, $countrySetting),
|
|
ChargePolicyEnum::PER_QUANTITY => $this->calculatePerUnitFee($countrySetting->base_fee, $groupQuantity, $countrySetting->ranges['unit_value'] ?? 1),
|
|
ChargePolicyEnum::PER_WEIGHT => $this->calculatePerUnitFee($countrySetting->base_fee, $weightKg, $countrySetting->ranges['unit_value'] ?? 0.5),
|
|
ChargePolicyEnum::PER_VOLUME => $this->calculatePerUnitFee($countrySetting->base_fee, $volumeLiter, $countrySetting->ranges['unit_value'] ?? 1),
|
|
ChargePolicyEnum::PER_VOLUME_WEIGHT => $this->calculatePerUnitFee($countrySetting->base_fee, $chargeableWeightKg, $countrySetting->ranges['unit_value'] ?? 0.5),
|
|
ChargePolicyEnum::PER_AMOUNT => $this->calculatePerUnitFee($countrySetting->base_fee, $groupTotal, $countrySetting->ranges['unit_value'] ?? 10000),
|
|
ChargePolicyEnum::API => $this->calculateApiShippingFee($countrySetting, $group),
|
|
default => (int) round((float) $countrySetting->base_fee),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 구간별 배송비를 계산합니다 (상한 포함 사다리 매칭).
|
|
*
|
|
* 구간을 상한 오름차순(무제한 구간을 마지막)으로 정렬한 뒤, 값이 상한 이하인 첫 구간의
|
|
* 배송비를 반환합니다. 시작값(min)은 화면 표시용이며 매칭에 사용하지 않습니다. 이 방식은
|
|
* 상한 포함형(max+1=다음 min)과 반개형(max=다음 min) 어느 형태로 저장된 데이터에서도
|
|
* 간극 없이 동작합니다.
|
|
*
|
|
* @param array|null $ranges 구간 설정
|
|
* @param int|float $value 비교 값 (금액·수량·kg·L)
|
|
* @param ShippingPolicyCountrySetting|null $countrySetting 로그 맥락용 국가별 설정
|
|
* @return int 배송비
|
|
*/
|
|
protected function calculateRangeFee(?array $ranges, int|float $value, ?ShippingPolicyCountrySetting $countrySetting = null): int
|
|
{
|
|
$tiers = is_array($ranges) ? ($ranges['tiers'] ?? []) : [];
|
|
|
|
if (! is_array($tiers) || count($tiers) === 0) {
|
|
$this->logZeroShippingFallback(
|
|
'구간별 배송비 구간(tiers)이 비어 있어 배송비를 0원으로 처리했습니다',
|
|
$countrySetting,
|
|
['value' => $value]
|
|
);
|
|
|
|
return 0;
|
|
}
|
|
|
|
// 상한 오름차순 정렬 (무제한 구간은 항상 마지막)
|
|
$sortedTiers = array_values($tiers);
|
|
usort($sortedTiers, function ($a, $b) {
|
|
$aMax = $this->resolveTierMax($a);
|
|
$bMax = $this->resolveTierMax($b);
|
|
|
|
if ($aMax === null && $bMax === null) {
|
|
return 0;
|
|
}
|
|
if ($aMax === null) {
|
|
return 1;
|
|
}
|
|
if ($bMax === null) {
|
|
return -1;
|
|
}
|
|
|
|
return $aMax <=> $bMax;
|
|
});
|
|
|
|
foreach ($sortedTiers as $tier) {
|
|
$max = $this->resolveTierMax($tier);
|
|
|
|
if ($max === null || (float) $value <= $max) {
|
|
return (int) round((float) ($tier['fee'] ?? 0));
|
|
}
|
|
}
|
|
|
|
// 마지막 구간의 max 가 null 이면 도달하지 않는 경로 (저장 시점 검증이 1차 방어)
|
|
$this->logZeroShippingFallback(
|
|
'구간별 배송비에서 매칭되는 구간을 찾지 못해 0원으로 처리했습니다',
|
|
$countrySetting,
|
|
['value' => $value]
|
|
);
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* 구간의 상한값을 정규화합니다 (무제한이면 null).
|
|
*
|
|
* @param array $tier 구간 정의
|
|
* @return float|null 상한값 (무제한이면 null)
|
|
*/
|
|
protected function resolveTierMax(array $tier): ?float
|
|
{
|
|
$max = $tier['max'] ?? null;
|
|
|
|
if ($max === null || $max === '') {
|
|
return null;
|
|
}
|
|
|
|
return (float) $max;
|
|
}
|
|
|
|
/**
|
|
* 배송비 0원 폴백을 경고 로그로 남깁니다.
|
|
*
|
|
* 설정 오류로 인한 무음 0원(무료배송)은 화면상 정상 주문과 구분되지 않으므로
|
|
* 운영자가 사후에 원인을 추적할 수 있도록 맥락을 남깁니다.
|
|
*
|
|
* @param string $message 로그 메시지
|
|
* @param ShippingPolicyCountrySetting|null $countrySetting 국가별 설정
|
|
* @param array $context 추가 맥락
|
|
*/
|
|
protected function logZeroShippingFallback(string $message, ?ShippingPolicyCountrySetting $countrySetting, array $context = []): void
|
|
{
|
|
Log::warning($message, array_merge([
|
|
'policy_id' => $countrySetting?->shipping_policy_id,
|
|
'country_code' => $countrySetting?->country_code,
|
|
'charge_policy' => $countrySetting?->charge_policy?->value,
|
|
], $context));
|
|
}
|
|
|
|
/**
|
|
* 단위당 배송비를 계산합니다.
|
|
*
|
|
* @param int|float|string $baseFee 기본 배송비 (decimal 캐스트로 문자열이 올 수 있음)
|
|
* @param int|float $value 계산 기준 값 (수량, kg, L, 금액 등)
|
|
* @param int|float $unitValue 단위 값
|
|
* @return int 배송비
|
|
*/
|
|
protected function calculatePerUnitFee(int|float|string $baseFee, int|float $value, int|float $unitValue): int
|
|
{
|
|
if ($unitValue <= 0) {
|
|
$unitValue = 1;
|
|
}
|
|
|
|
return (int) round(ceil($value / $unitValue) * (float) $baseFee);
|
|
}
|
|
|
|
/**
|
|
* per_* 정책에서 부과 단위 수를 계산합니다 (기본 배송비와 도서산간 곱의 공통 기준).
|
|
*
|
|
* @param ShippingPolicyCountrySetting $countrySetting 국가별 설정
|
|
* @param array $group 그룹 정보 (total_amount, total_quantity, total_weight, total_volume)
|
|
* @return int 부과 단위 수
|
|
*/
|
|
protected function resolveChargeUnits(ShippingPolicyCountrySetting $countrySetting, array $group): int
|
|
{
|
|
$groupWeight = (float) ($group['total_weight'] ?? 0.0);
|
|
$groupVolume = (float) ($group['total_volume'] ?? 0.0);
|
|
$volumeWeightDivisor = $countrySetting->ranges['volume_weight_divisor'] ?? 6000;
|
|
|
|
$weightKg = $groupWeight / 1000;
|
|
$volumeLiter = $groupVolume / 1000;
|
|
$volumetricWeightKg = $volumeWeightDivisor > 0 ? $groupVolume / $volumeWeightDivisor : 0.0;
|
|
|
|
[$value, $defaultUnitValue] = match ($countrySetting->charge_policy) {
|
|
ChargePolicyEnum::PER_WEIGHT => [$weightKg, 0.5],
|
|
ChargePolicyEnum::PER_VOLUME => [$volumeLiter, 1],
|
|
ChargePolicyEnum::PER_VOLUME_WEIGHT => [max($weightKg, $volumetricWeightKg), 0.5],
|
|
ChargePolicyEnum::PER_AMOUNT => [(float) ($group['total_amount'] ?? 0), 10000],
|
|
default => [(float) ($group['total_quantity'] ?? 0), 1],
|
|
};
|
|
|
|
$unitValue = $countrySetting->ranges['unit_value'] ?? $defaultUnitValue;
|
|
if ($unitValue <= 0) {
|
|
$unitValue = 1;
|
|
}
|
|
|
|
return max(0, (int) ceil($value / $unitValue));
|
|
}
|
|
|
|
/**
|
|
* API를 통해 배송비를 계산합니다.
|
|
*
|
|
* @param ShippingPolicyCountrySetting $countrySetting 국가별 설정
|
|
* @param array $group 그룹 정보
|
|
* @return int 배송비
|
|
*/
|
|
protected function calculateApiShippingFee(ShippingPolicyCountrySetting $countrySetting, array $group): int
|
|
{
|
|
$apiEndpoint = $countrySetting->api_endpoint;
|
|
|
|
// 엔드포인트 미설정 시 기본 배송비 폴백
|
|
if (empty($apiEndpoint)) {
|
|
return (int) round((float) $countrySetting->base_fee);
|
|
}
|
|
|
|
try {
|
|
$config = is_array($countrySetting->api_config) ? $countrySetting->api_config : [];
|
|
|
|
$requestData = $this->buildApiRequestData($countrySetting, $group, $config);
|
|
$response = $this->dispatchApiRequest($apiEndpoint, $requestData, $config);
|
|
|
|
if ($response === null || ! $response->successful()) {
|
|
return (int) round((float) $countrySetting->base_fee);
|
|
}
|
|
|
|
$fee = $this->extractFeeFromApiResponse($response, $countrySetting, $config);
|
|
|
|
return $fee ?? (int) round((float) $countrySetting->base_fee);
|
|
} catch (\Throwable $e) {
|
|
// 예외 발생 시 기본 배송비 반환 (토큰 등 민감값은 로그에 남기지 않음)
|
|
Log::warning('API 배송비 계산 실패', [
|
|
'policy_id' => $countrySetting->shipping_policy_id,
|
|
'country_code' => $countrySetting->country_code,
|
|
'http_method' => $countrySetting->api_config['http_method'] ?? 'POST',
|
|
'auth_type' => $countrySetting->api_config['auth_type'] ?? 'none',
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return (int) round((float) $countrySetting->base_fee);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 외부 배송비 계산 API 요청 데이터를 구성합니다 (후보 필터 + 외부 키 매핑 적용).
|
|
*
|
|
* @param ShippingPolicyCountrySetting $countrySetting 국가별 설정
|
|
* @param array $group 그룹 정보
|
|
* @param array $config api_config 설정
|
|
* @return array<string, mixed> 전송할 요청 데이터
|
|
*/
|
|
protected function buildApiRequestData(ShippingPolicyCountrySetting $countrySetting, array $group, array $config): array
|
|
{
|
|
$requestData = [
|
|
'policy_id' => $countrySetting->shipping_policy_id,
|
|
'country_code' => $countrySetting->country_code,
|
|
'items' => array_map(fn ($item) => [
|
|
'product_option_id' => $item['product_option_id'],
|
|
'quantity' => $item['quantity'],
|
|
'subtotal' => $item['subtotal'],
|
|
'weight' => $item['weight'] ?? 0,
|
|
'volume' => $item['volume'] ?? 0,
|
|
], $group['items'] ?? []),
|
|
'group_total' => $group['total_amount'] ?? 0,
|
|
'total_quantity' => $group['total_quantity'] ?? 0,
|
|
];
|
|
|
|
// 전송 필드가 지정된 경우 후보만 포함 (silent drop 차단 — 후보 SSoT 검증 완료)
|
|
if (! empty($countrySetting->api_request_fields)) {
|
|
$filteredData = [];
|
|
foreach ($countrySetting->api_request_fields as $field) {
|
|
if (array_key_exists($field, $requestData)) {
|
|
$filteredData[$field] = $requestData[$field];
|
|
}
|
|
}
|
|
$requestData = $filteredData;
|
|
}
|
|
|
|
// 외부 키 매핑(field_map) 적용 — 우리 키를 외부 API 가 요구하는 키 이름으로 리네임
|
|
$fieldMap = $config['field_map'] ?? [];
|
|
if (is_array($fieldMap) && ! empty($fieldMap)) {
|
|
$mapped = [];
|
|
foreach ($requestData as $key => $value) {
|
|
$externalKey = ! empty($fieldMap[$key]) ? $fieldMap[$key] : $key;
|
|
$mapped[$externalKey] = $value;
|
|
}
|
|
$requestData = $mapped;
|
|
}
|
|
|
|
return $requestData;
|
|
}
|
|
|
|
/**
|
|
* 설정에 따라 HTTP 메서드/인증 헤더를 적용해 외부 API 를 호출합니다.
|
|
*
|
|
* GET 은 query string(bracket 표기 자동 직렬화), POST 는 JSON body 로 전송합니다.
|
|
*
|
|
* @param string $endpoint API 엔드포인트 URL
|
|
* @param array $requestData 전송 데이터
|
|
* @param array $config api_config 설정
|
|
* @return Response|null 응답 (실패 시 null)
|
|
*/
|
|
protected function dispatchApiRequest(string $endpoint, array $requestData, array $config): ?Response
|
|
{
|
|
// 이 호출은 공개 체크아웃 트래픽으로도 유발되므로, 저장된 엔드포인트가 내부망을
|
|
// 가리키면 요청 자체를 보내지 않는다. 저장 시점 검증(FormRequest) 도입 이전에
|
|
// 기록된 값이나 DB 직접 수정으로 들어온 값을 여기서 막는다.
|
|
if (! $this->isEndpointAllowed($endpoint)) {
|
|
Log::warning('배송비 계산 API 엔드포인트가 내부 네트워크를 가리켜 호출을 차단했습니다', [
|
|
'host' => parse_url($endpoint, PHP_URL_HOST),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$request = Http::timeout(10)->withoutRedirecting();
|
|
|
|
// 인증 헤더 부착
|
|
$authType = $config['auth_type'] ?? ShippingApiAuthType::NONE->value;
|
|
$token = $config['auth_token'] ?? null;
|
|
|
|
if ($authType === ShippingApiAuthType::BEARER->value && ! empty($token)) {
|
|
$request = $request->withToken($token);
|
|
} elseif ($authType === ShippingApiAuthType::CUSTOM_HEADER->value
|
|
&& ! empty($token) && ! empty($config['auth_header_name'])) {
|
|
$request = $request->withHeaders([$config['auth_header_name'] => $token]);
|
|
}
|
|
|
|
$method = strtoupper($config['http_method'] ?? ShippingApiHttpMethod::POST->value);
|
|
|
|
// GET 은 query string(items 배열은 bracket 표기로 자동 직렬화), POST 는 JSON body
|
|
return $method === ShippingApiHttpMethod::GET->value
|
|
? $request->get($endpoint, $requestData)
|
|
: $request->post($endpoint, $requestData);
|
|
}
|
|
|
|
/**
|
|
* 배송비 계산 API 엔드포인트가 호출 가능한 주소인지 판정합니다.
|
|
*
|
|
* 사설 IP·localhost 등 내부 네트워크 주소는 기본 차단합니다. 사내 서버를 배송비 API 로
|
|
* 쓰는 운영 환경을 위해 관리자 환경설정의 `security.allow_internal_outbound_urls` 로
|
|
* 허용할 수 있으며, 그 경우에도 userinfo 위장·비 HTTP scheme 은 계속 거부합니다.
|
|
*
|
|
* @param string $endpoint API 엔드포인트 URL
|
|
* @return bool 호출을 허용하면 true
|
|
*/
|
|
protected function isEndpointAllowed(string $endpoint): bool
|
|
{
|
|
$options = ['schemes' => ['http', 'https']];
|
|
|
|
if ((bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
|
|
return OutboundUrlValidator::isStructurallySafeUrl($endpoint, $options);
|
|
}
|
|
|
|
return OutboundUrlValidator::isPublicHttpUrl($endpoint, $options);
|
|
}
|
|
|
|
/**
|
|
* 외부 API 응답에서 배송비 값을 추출합니다 (응답 형식별 분기).
|
|
*
|
|
* - json: response_path 점표기 중첩 경로로 추출
|
|
* - text: 본문에서 숫자/소수점만 남겨 캐스팅 (통화기호·콤마 제거)
|
|
*
|
|
* @param Response $response API 응답
|
|
* @param ShippingPolicyCountrySetting $countrySetting 국가별 설정
|
|
* @param array $config api_config 설정
|
|
* @return int|null 추출된 배송비 (추출 실패 시 null)
|
|
*/
|
|
protected function extractFeeFromApiResponse($response, ShippingPolicyCountrySetting $countrySetting, array $config): ?int
|
|
{
|
|
$responseType = $config['response_type'] ?? ShippingApiResponseType::JSON->value;
|
|
|
|
if ($responseType === ShippingApiResponseType::TEXT->value) {
|
|
// 텍스트 응답: 숫자/소수점/부호만 남겨 추출 ("₩3,000" → 3000)
|
|
$cleaned = preg_replace('/[^0-9.\-]/', '', $response->body());
|
|
|
|
return is_numeric($cleaned) ? $this->normalizeApiFee((int) $cleaned, $countrySetting) : null;
|
|
}
|
|
|
|
// JSON 응답: response_path 점표기 중첩 경로 추출 (없으면 기존 api_response_fee_field 폴백)
|
|
$path = $config['response_path']
|
|
?? $countrySetting->api_response_fee_field
|
|
?? 'shipping_fee';
|
|
|
|
$value = data_get($response->json(), $path);
|
|
|
|
return is_numeric($value) ? $this->normalizeApiFee((int) $value, $countrySetting) : null;
|
|
}
|
|
|
|
/**
|
|
* 외부 API 가 돌려준 배송비를 검증합니다.
|
|
*
|
|
* 음수 배송비는 주문 총액을 깎아 금전 손실로 이어지므로 추출 실패로 처리해
|
|
* 기본 배송비 폴백 경로에 태웁니다.
|
|
*
|
|
* @param int $fee 추출된 배송비
|
|
* @param ShippingPolicyCountrySetting $countrySetting 국가별 설정
|
|
* @return int|null 검증을 통과한 배송비 (음수면 null)
|
|
*/
|
|
protected function normalizeApiFee(int $fee, ShippingPolicyCountrySetting $countrySetting): ?int
|
|
{
|
|
if ($fee < 0) {
|
|
Log::warning('배송비 계산 API 가 음수 배송비를 반환해 기본 배송비로 폴백합니다', [
|
|
'policy_id' => $countrySetting->shipping_policy_id,
|
|
'country_code' => $countrySetting->country_code,
|
|
'fee' => $fee,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
return $fee;
|
|
}
|
|
|
|
/**
|
|
* 관리자 입력 설정으로 배송비 계산 API 를 1회 테스트 호출합니다.
|
|
*
|
|
* 요청 미리보기 + 응답(상태/본문 일부) + 추출 배송비를 반환합니다. 응답 본문은 길이를
|
|
* 제한하며, 인증 토큰은 반환값에 노출하지 않습니다.
|
|
*
|
|
* @param string $endpoint API 엔드포인트
|
|
* @param array $config api_config 설정 (auth_token 평문 허용 — 호출에만 사용, 미반환)
|
|
* @param array|null $requestFields api_request_fields
|
|
* @param array $sample 샘플 데이터 (group_total/total_quantity/country_code)
|
|
* @return array<string, mixed> 테스트 결과
|
|
*/
|
|
public function testApiCall(string $endpoint, array $config, ?array $requestFields, array $sample = []): array
|
|
{
|
|
|
|
// transient country setting (DB 저장 없음) — 계산 헬퍼 재사용
|
|
$countrySetting = new ShippingPolicyCountrySetting([
|
|
'shipping_policy_id' => 0,
|
|
'country_code' => $sample['country_code'] ?? 'KR',
|
|
'api_endpoint' => $endpoint,
|
|
'api_request_fields' => $requestFields,
|
|
'base_fee' => 0,
|
|
]);
|
|
|
|
// 현 정책 기준 더미 그룹 (관리자 샘플 값으로 일부 덮어쓰기)
|
|
$group = [
|
|
'items' => [
|
|
['product_option_id' => 0, 'quantity' => 1, 'subtotal' => $sample['group_total'] ?? 10000, 'weight' => 1, 'volume' => 1],
|
|
],
|
|
'total_amount' => $sample['group_total'] ?? 10000,
|
|
'total_quantity' => $sample['total_quantity'] ?? 1,
|
|
];
|
|
|
|
$requestData = $this->buildApiRequestData($countrySetting, $group, $config);
|
|
$method = strtoupper($config['http_method'] ?? ShippingApiHttpMethod::POST->value);
|
|
|
|
// 요청 미리보기는 성공/실패와 무관하게 항상 반환 (진단 목적)
|
|
$requestPreview = [
|
|
'method' => $method,
|
|
'endpoint' => $endpoint,
|
|
'data' => $requestData,
|
|
'body' => json_encode($requestData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
|
|
];
|
|
|
|
try {
|
|
$response = $this->dispatchApiRequest($endpoint, $requestData, $config);
|
|
|
|
if ($response === null) {
|
|
return [
|
|
'ok' => false,
|
|
'reason' => 'no_response',
|
|
'request' => $requestPreview,
|
|
];
|
|
}
|
|
|
|
$extracted = $this->extractFeeFromApiResponse($response, $countrySetting, $config);
|
|
|
|
return [
|
|
// HTTP 응답을 받았으면 ok=true (응답 자체는 도달). 추출 성공 여부는 extracted_fee 로 구분.
|
|
'ok' => $response->successful(),
|
|
'http_ok' => $response->successful(),
|
|
'request' => $requestPreview,
|
|
'response' => [
|
|
'status' => $response->status(),
|
|
// 응답 본문은 4KB 로 제한 (과대 응답 방어). 성공/실패 무관하게 항상 노출.
|
|
'body' => mb_substr($response->body(), 0, 4096),
|
|
],
|
|
'extracted_fee' => $extracted,
|
|
// 화면이 통화 단위를 이어 붙이지 않도록 포맷 문자열을 함께 내보낸다 —
|
|
// 배송비는 기본 통화 기준이므로 원화를 못 박으면 그 상점에서 단위만 틀린다.
|
|
'extracted_fee_formatted' => $extracted !== null
|
|
? ecommerce_format_price((float) $extracted, $countrySetting->currency_code ?? null)
|
|
: null,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
// 연결 실패·타임아웃 등 — 요청 미리보기 + 에러 메시지를 함께 반환 (진단)
|
|
return [
|
|
'ok' => false,
|
|
'reason' => 'request_failed',
|
|
'error' => $e->getMessage(),
|
|
'request' => $requestPreview,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 배송비를 그룹 내 아이템들에게 안분합니다.
|
|
*
|
|
* @param array $items 그룹 내 아이템 배열
|
|
* @param int $totalFee 총 배송비
|
|
* @return array 아이템별 배송비 안분 결과
|
|
*/
|
|
protected function apportionShippingFee(array $items, int $totalFee): array
|
|
{
|
|
if (empty($items) || $totalFee <= 0) {
|
|
$result = [];
|
|
foreach ($items as $item) {
|
|
$result[$item['product_option_id']] = ['amount' => 0];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
$groupTotal = array_sum(array_column($items, 'subtotal'));
|
|
if ($groupTotal <= 0) {
|
|
// 균등 분배
|
|
$share = (int) floor($totalFee / count($items));
|
|
$result = [];
|
|
$allocated = 0;
|
|
|
|
foreach ($items as $index => $item) {
|
|
$isLast = ($index === count($items) - 1);
|
|
$result[$item['product_option_id']] = [
|
|
'amount' => $isLast ? ($totalFee - $allocated) : $share,
|
|
];
|
|
$allocated += $share;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
$result = [];
|
|
$allocated = 0;
|
|
$itemCount = count($items);
|
|
|
|
foreach ($items as $index => $item) {
|
|
$isLast = ($index === $itemCount - 1);
|
|
|
|
if ($isLast) {
|
|
$share = $totalFee - $allocated;
|
|
} else {
|
|
$ratio = $item['subtotal'] / $groupTotal;
|
|
$share = (int) round($totalFee * $ratio);
|
|
$allocated += $share;
|
|
}
|
|
|
|
$result[$item['product_option_id']] = ['amount' => $share];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 상품 프로모션에서 옵션별 적용 쿠폰을 추출합니다.
|
|
*
|
|
* @param AppliedPromotions $productPromotions 상품 프로모션 정보
|
|
* @return array<int, AppliedPromotions> 옵션별 적용 프로모션 [product_option_id => AppliedPromotions]
|
|
*/
|
|
protected function buildPromotionsByOption(AppliedPromotions $productPromotions): array
|
|
{
|
|
$promotionsByOption = [];
|
|
|
|
foreach ($productPromotions->coupons as $couponApplication) {
|
|
if (empty($couponApplication->appliedItems)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($couponApplication->appliedItems as $appliedItem) {
|
|
$optionId = $appliedItem['product_option_id'] ?? null;
|
|
if ($optionId === null) {
|
|
continue;
|
|
}
|
|
|
|
// 해당 옵션에 대한 AppliedPromotions가 없으면 생성
|
|
if (! isset($promotionsByOption[$optionId])) {
|
|
$promotionsByOption[$optionId] = new AppliedPromotions;
|
|
}
|
|
|
|
// 해당 옵션에 대한 CouponApplication 생성 (해당 옵션의 할인액만 포함)
|
|
$optionCouponApplication = new CouponApplication(
|
|
couponId: $couponApplication->couponId,
|
|
couponIssueId: $couponApplication->couponIssueId,
|
|
name: $couponApplication->name,
|
|
targetType: $couponApplication->targetType,
|
|
targetScope: $couponApplication->targetScope,
|
|
discountType: $couponApplication->discountType,
|
|
discountValue: $couponApplication->discountValue,
|
|
totalDiscount: $appliedItem['discount_amount'] ?? 0,
|
|
totalDiscountFormatted: $appliedItem['discount_amount_formatted'] ?? '',
|
|
appliedItems: [$appliedItem],
|
|
isExclusive: $couponApplication->isExclusive,
|
|
minOrderAmount: $couponApplication->minOrderAmount,
|
|
maxDiscountAmount: $couponApplication->maxDiscountAmount,
|
|
);
|
|
|
|
$promotionsByOption[$optionId]->addCoupon($optionCouponApplication);
|
|
}
|
|
}
|
|
|
|
return $promotionsByOption;
|
|
}
|
|
|
|
/**
|
|
* 금액을 아이템별로 안분합니다 (플러그인용 공개 메서드).
|
|
*
|
|
* 플러그인에서 예치금, 상품권 등 추가 결제 수단의 안분 계산에 사용합니다.
|
|
*
|
|
* @param array $items 아이템 배열 [product_option_id => ['subtotal' => int, ...], ...]
|
|
* @param int $totalAmount 안분할 총 금액
|
|
* @return array 아이템별 안분 금액 [product_option_id => amount]
|
|
*
|
|
* @example
|
|
* // 예치금 플러그인에서 사용
|
|
* $depositShares = $calculationService->apportionAmountByItems(
|
|
* $discountedItems, // 할인 후 아이템 배열
|
|
* $useDeposit // 사용할 예치금
|
|
* );
|
|
* // 결과: [option_1 => 3000, option_2 => 2000]
|
|
*/
|
|
public function apportionAmountByItems(array $items, int $totalAmount): array
|
|
{
|
|
return $this->apportionAmount($items, $totalAmount);
|
|
}
|
|
|
|
/**
|
|
* 금액을 아이템별로 안분합니다.
|
|
*
|
|
* @param array $items 아이템 배열
|
|
* @param int $totalAmount 안분할 총 금액
|
|
* @return array 아이템별 안분 금액 [product_option_id => amount]
|
|
*/
|
|
protected function apportionAmount(array $items, int $totalAmount): array
|
|
{
|
|
if (empty($items) || $totalAmount <= 0) {
|
|
$result = [];
|
|
foreach ($items as $optionId => $item) {
|
|
$result[$optionId] = 0;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
$totalSubtotal = 0;
|
|
foreach ($items as $item) {
|
|
$totalSubtotal += $item['subtotal'] ?? 0;
|
|
}
|
|
|
|
if ($totalSubtotal <= 0) {
|
|
// 균등 분배
|
|
$count = count($items);
|
|
$share = (int) floor($totalAmount / $count);
|
|
$result = [];
|
|
$allocated = 0;
|
|
$index = 0;
|
|
|
|
foreach ($items as $optionId => $item) {
|
|
$isLast = ($index === $count - 1);
|
|
$result[$optionId] = $isLast ? ($totalAmount - $allocated) : $share;
|
|
$allocated += $share;
|
|
$index++;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
$result = [];
|
|
$allocated = 0;
|
|
$itemCount = count($items);
|
|
$index = 0;
|
|
|
|
foreach ($items as $optionId => $item) {
|
|
$isLast = ($index === $itemCount - 1);
|
|
|
|
if ($isLast) {
|
|
$share = $totalAmount - $allocated;
|
|
} else {
|
|
$subtotal = $item['subtotal'] ?? 0;
|
|
$ratio = $subtotal / $totalSubtotal;
|
|
$share = (int) round($totalAmount * $ratio);
|
|
$allocated += $share;
|
|
}
|
|
|
|
$result[$optionId] = $share;
|
|
$index++;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 스냅샷 데이터에서 가상 ShippingPolicy 모델을 생성합니다.
|
|
*
|
|
* @param int $policyId 배송정책 ID
|
|
* @param array $snapshotData AppliedShippingPolicy 직렬화 데이터
|
|
* @return ShippingPolicy 스냅샷 기반 가상 배송정책
|
|
*/
|
|
protected function buildSnapshotShippingPolicy(int $policyId, array $snapshotData): ShippingPolicy
|
|
{
|
|
$policyName = $snapshotData['policy_name'] ?? '';
|
|
|
|
$policy = new ShippingPolicy;
|
|
$policy->id = $policyId;
|
|
$policy->name = ['ko' => $policyName, 'en' => $policyName];
|
|
$policy->is_active = true;
|
|
|
|
return $policy;
|
|
}
|
|
|
|
/**
|
|
* 스냅샷의 policy_snapshot 데이터에서 ShippingPolicyCountrySetting 모델을 생성합니다.
|
|
*
|
|
* @param array $snapshotData AppliedShippingPolicy 직렬화 데이터
|
|
* @return ShippingPolicyCountrySetting|null 스냅샷 기반 국가별 설정
|
|
*/
|
|
protected function buildSnapshotCountrySetting(array $snapshotData): ?ShippingPolicyCountrySetting
|
|
{
|
|
$policySnapshot = $snapshotData['policy_snapshot'] ?? null;
|
|
|
|
if (! $policySnapshot || ($policySnapshot['country_setting'] ?? null) === null && empty($policySnapshot['charge_policy'])) {
|
|
return null;
|
|
}
|
|
|
|
return new ShippingPolicyCountrySetting([
|
|
'country_code' => $policySnapshot['country_code'] ?? 'KR',
|
|
'shipping_method' => $policySnapshot['shipping_method'] ?? null,
|
|
'currency_code' => $policySnapshot['currency_code'] ?? 'KRW',
|
|
'charge_policy' => $policySnapshot['charge_policy'] ?? 'fixed',
|
|
'base_fee' => $policySnapshot['base_fee'] ?? 0,
|
|
'free_threshold' => $policySnapshot['free_threshold'] ?? null,
|
|
'ranges' => $policySnapshot['ranges'] ?? [],
|
|
'extra_fee_enabled' => $policySnapshot['extra_fee_enabled'] ?? false,
|
|
'extra_fee_multiply' => $policySnapshot['extra_fee_multiply'] ?? false,
|
|
'extra_fee_settings' => $policySnapshot['extra_fee_settings'] ?? [],
|
|
'is_active' => true,
|
|
]);
|
|
}
|
|
}
|