Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCalculationServiceTest.php
T
HeuJung 55bc9399e6 fix(ecommerce): 배송비 구간 경계 포함 + 무게/부피 단위 환산
구간별 배송비의 종료값이 구간에 포함되지 않아 경계값이 무료배송으로
새고, 상품 옵션의 g/cm³ 저장값을 정책의 kg/L 로 환산하지 않아 금액이
1000배로 청구되던 문제를 수정한다.

- 구간 매칭을 종료값 오름차순 사다리로 교체 — 시작값은 표시 전용이
 되어 기존 저장 데이터의 두 형태 모두에서 정확히 계산된다
- 단위 환산을 배송비 계산 한 지점으로 모으고, 부피무게와 실무게를
 같은 kg 단위로 비교하도록 교정
- 계산이 조용히 0원이 되는 설정(구간 미등록·단위값/기준금액 누락·
 중간 구간 무제한·계산 API 폴백 0원)을 저장 시점에 차단하고,
 런타임 잔여 경로에는 경고 로그를 남긴다
- 연속성 규칙을 정책 타입별로 분리해 소수 구간(2.5kg) 저장을 허용
- 상품 옵션 무게/부피 응답 왕복, 주문 총 무게/부피 실값 기록
2026-08-12 23:43:08 +09:00

8385 lines
310 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Models\User;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress;
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
use Modules\Sirsoft\Ecommerce\Enums\CouponDiscountType;
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
use Modules\Sirsoft\Ecommerce\Enums\ProductTaxStatus;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicyCountrySetting;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 주문 계산 서비스 기본 Unit 테스트
*
* OrderCalculationService의 기본 계산 기능을 검증합니다.
* - 옵션별 판매금액 계산
* - 상품/카테고리 쿠폰 적용
* - 배송비 계산
* - 과세/면세 분류
* - 쿠폰 검증 규칙
* - 안분 계산 정확성
* - 복합 시나리오
*/
class OrderCalculationServiceTest extends ModuleTestCase
{
protected OrderCalculationService $service;
protected function setUp(): void
{
parent::setUp();
// 테스트용 통화 설정 주입
$this->setupTestCurrencySettings();
// 서비스 인스턴스 생성 (DI 컨테이너를 통해 resolve)
$this->service = app(OrderCalculationService::class);
}
/**
* 테스트용 통화 설정을 저장합니다.
*/
protected function setupTestCurrencySettings(): void
{
$settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($settingsPath)) {
mkdir($settingsPath, 0755, true);
}
$settings = [
'default_language' => 'ko',
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
'exchange_rate' => null,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'is_default' => true,
],
],
];
file_put_contents(
$settingsPath.'/language_currency.json',
json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
// 마일리지 기능 활성화 (기본 적립률 1% — 적립 계산 검증용).
// mileage.enabled=false 시 기본 적립률 적립이 0 이 되는 것은 정상 동작이므로,
// 적립 계산을 검증하는 테스트는 기능을 켠 상태를 전제로 한다.
file_put_contents(
$settingsPath.'/mileage.json',
json_encode([
'enabled' => true,
'default_earn_rate' => 1,
'earn_trigger' => 'confirmed',
'earn_delay_days' => 0,
'currency_rules' => [
['currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1, 'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0],
],
'expiry_enabled' => true,
'expiry_days' => 365,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
}
protected function tearDown(): void
{
// 테스트 설정 파일 정리
$settingsFile = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/language_currency.json');
if (file_exists($settingsFile)) {
unlink($settingsFile);
}
$mileageFile = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/mileage.json');
if (file_exists($mileageFile)) {
unlink($mileageFile);
}
parent::tearDown();
}
/**
* 테스트용 상품과 옵션을 생성합니다.
*
* @param int $price 상품 판매가
* @param int $priceAdjustment 옵션 추가금액
* @return array [Product, ProductOption]
*/
protected function createProductWithOption(
int $price = 50000,
int $priceAdjustment = 0
): array {
$product = ProductFactory::new()->create([
'tax_status' => 'taxable',
'selling_price' => $price,
'list_price' => $price,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => $priceAdjustment,
'stock_quantity' => 100,
'is_default' => true,
]);
return [$product, $option];
}
// ========================================
// Section 7.1: 옵션별 판매금액 계산 (3개)
// ========================================
/**
* 테스트 1: 단일 옵션 소계 계산
*
* 입력: 옵션A: 10,000원 × 2개
* 기대: subtotal = 20,000원
*/
public function test_it_calculates_single_option_subtotal(): void
{
// Given: 10,000원 상품, 옵션 추가금액 0원
[$product, $option] = $this->createProductWithOption(
price: 10000,
priceAdjustment: 0
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 2
),
]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertCount(1, $result->items);
$this->assertEquals(10000, $result->items[0]->unitPrice);
$this->assertEquals(2, $result->items[0]->quantity);
$this->assertEquals(20000, $result->items[0]->subtotal);
}
/**
* 테스트 2: 다중 옵션 소계 합산
*
* 입력: 옵션A: 10,000원 × 2개, 옵션B: 5,000원 × 3개
* 기대: summary.subtotal = 35,000원
*/
public function test_it_calculates_multiple_options_subtotal_sum(): void
{
// Given: 상품A 10,000원, 상품B 5,000원
[$productA, $optionA] = $this->createProductWithOption(price: 10000, priceAdjustment: 0);
[$productB, $optionB] = $this->createProductWithOption(price: 5000, priceAdjustment: 0);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 2
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 3
),
]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertCount(2, $result->items);
// 옵션A: 10,000 × 2 = 20,000
$this->assertEquals(20000, $result->items[0]->subtotal);
// 옵션B: 5,000 × 3 = 15,000
$this->assertEquals(15000, $result->items[1]->subtotal);
// 합계: 20,000 + 15,000 = 35,000
$this->assertEquals(35000, $result->summary->subtotal);
}
/**
* 테스트 3: 수량 0인 옵션 제외
*
* 입력: 옵션A: 10,000원 × 0개
* 기대: 계산 대상에서 제외되어 빈 결과 반환
*/
public function test_it_excludes_zero_quantity_option(): void
{
// Given: 10,000원 상품, 수량 0
[$product, $option] = $this->createProductWithOption(price: 10000, priceAdjustment: 0);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 0
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 수량 0인 아이템은 prepareItems에서 로드되지만,
// subtotal 계산에서는 0 × 가격 = 0이 됨
// 실제 구현에 따라 빈 배열이거나 subtotal=0일 수 있음
if (count($result->items) === 0) {
// 빈 결과인 경우
$this->assertEquals(0, $result->summary->subtotal);
} else {
// 아이템이 있지만 subtotal이 0인 경우
$this->assertEquals(0, $result->items[0]->subtotal);
$this->assertEquals(0, $result->summary->subtotal);
}
}
// ========================================
// Section 7.2: 상품/카테고리 쿠폰 적용 (6개)
// ========================================
/**
* 테스트용 쿠폰과 발급 내역을 생성합니다.
*
* @param CouponTargetType $targetType 적용 대상 타입
* @param CouponDiscountType $discountType 할인 타입
* @param float $discountValue 할인 값
* @param CouponTargetScope $targetScope 적용 범위
* @param float|null $maxDiscount 최대 할인금액
* @return CouponIssue 쿠폰 발급 내역
*/
protected function createCouponWithIssue(
CouponTargetType $targetType = CouponTargetType::PRODUCT_AMOUNT,
CouponDiscountType $discountType = CouponDiscountType::FIXED,
float $discountValue = 1000,
CouponTargetScope $targetScope = CouponTargetScope::ALL,
?float $maxDiscount = null,
): CouponIssue {
$coupon = Coupon::create([
'name' => ['ko' => '테스트 쿠폰', 'en' => 'Test Coupon'],
'description' => ['ko' => '테스트용 쿠폰', 'en' => 'Test coupon'],
'target_type' => $targetType,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'discount_max_amount' => $maxDiscount,
'min_order_amount' => 0,
'target_scope' => $targetScope,
'is_combinable' => true,
'valid_from' => now()->subDay(),
'valid_to' => now()->addDays(30),
]);
// 테스트용 사용자 생성
$user = User::factory()->create();
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'coupon_code' => 'TEST'.uniqid(),
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expired_at' => now()->addDays(30),
]);
return $couponIssue;
}
/**
* 테스트용 카테고리를 생성합니다.
*
* @param string $name 카테고리명
*/
protected function createCategory(string $name = '테스트 카테고리'): Category
{
$category = Category::create([
'name' => ['ko' => $name, 'en' => 'Test Category'],
'slug' => 'test-category-'.uniqid(),
'path' => '1',
'depth' => 0,
'is_active' => true,
]);
// path 업데이트 (자기 ID 기반)
$category->update(['path' => (string) $category->id]);
return $category;
}
/**
* 테스트 4: 전체상품 정액 할인
*
* 입력: 쿠폰: all, fixed 1,000원
* 기대: 각 옵션에 금액 비율로 할인 적용
*/
public function test_it_applies_fixed_discount_to_all_products(): void
{
// Given: 상품A 20,000원, 상품B 10,000원
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
// 전체상품 정액 1,000원 할인 쿠폰
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id],
);
// When
$result = $this->service->calculate($input);
// Then: 각 옵션에 정액 할인 적용 (각각 1,000원씩)
$this->assertEquals(1000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(1000, $result->items[1]->productCouponDiscountAmount);
$this->assertEquals(2000, $result->summary->productCouponDiscount);
}
/**
* 테스트 5: 전체상품 정률 할인
*
* 입력: 쿠폰: all, rate 10%
* 기대: 각 옵션에 10% 할인 적용
*/
public function test_it_applies_rate_discount_to_all_products(): void
{
// Given: 상품A 20,000원, 상품B 10,000원
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
// 전체상품 정률 10% 할인 쿠폰
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id],
);
// When
$result = $this->service->calculate($input);
// Then: 각 옵션에 10% 할인
// 상품A: 20,000 × 10% = 2,000원
// 상품B: 10,000 × 10% = 1,000원
$this->assertEquals(2000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(1000, $result->items[1]->productCouponDiscountAmount);
$this->assertEquals(3000, $result->summary->productCouponDiscount);
}
/**
* 테스트 6: 특정상품 정액 할인
*
* 입력: 쿠폰: products [1,2], fixed 2,000원
* 기대: 상품1,2만 할인
*/
public function test_it_applies_fixed_discount_to_specific_products(): void
{
// Given: 상품A 20,000원, 상품B 10,000원, 상품C 15,000원
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
[$productC, $optionC] = $this->createProductWithOption(price: 15000);
// 특정상품 정액 2,000원 할인 쿠폰 (상품A, B만)
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
targetScope: CouponTargetScope::PRODUCTS
);
// 쿠폰에 적용 상품 연결
$couponIssue->coupon->includedProducts()->attach([$productA->id, $productB->id], ['type' => 'include']);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
new CalculationItem(
productId: $productC->id,
productOptionId: $optionC->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id],
);
// When
$result = $this->service->calculate($input);
// Then: 상품A, B만 할인 적용, 상품C는 할인 없음
$this->assertEquals(2000, $result->items[0]->productCouponDiscountAmount); // 상품A
$this->assertEquals(2000, $result->items[1]->productCouponDiscountAmount); // 상품B
$this->assertEquals(0, $result->items[2]->productCouponDiscountAmount); // 상품C
$this->assertEquals(4000, $result->summary->productCouponDiscount);
}
/**
* 테스트 7: 특정상품 정률 할인
*
* 입력: 쿠폰: products [1], rate 15%
* 기대: 상품1만 15% 할인
*/
public function test_it_applies_rate_discount_to_specific_product(): void
{
// Given: 상품A 20,000원, 상품B 10,000원
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
// 특정상품 정률 15% 할인 쿠폰 (상품A만)
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 15,
targetScope: CouponTargetScope::PRODUCTS
);
// 쿠폰에 적용 상품 연결
$couponIssue->coupon->includedProducts()->attach([$productA->id], ['type' => 'include']);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id],
);
// When
$result = $this->service->calculate($input);
// Then: 상품A만 15% 할인 = 3,000원, 상품B는 할인 없음
$this->assertEquals(3000, $result->items[0]->productCouponDiscountAmount); // 상품A: 20,000 × 15%
$this->assertEquals(0, $result->items[1]->productCouponDiscountAmount); // 상품B: 할인 없음
$this->assertEquals(3000, $result->summary->productCouponDiscount);
}
/**
* 테스트 8: 특정카테고리 할인
*
* 입력: 쿠폰: categories [10], rate 20%
* 기대: 카테고리10 상품만 20% 할인
*/
public function test_it_applies_rate_discount_to_specific_category(): void
{
// Given: 카테고리 생성
$category = $this->createCategory('테스트 카테고리');
// 상품A는 카테고리에 속함, 상품B는 카테고리 없음
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
// 상품A에 카테고리 연결
$productA->categories()->attach($category->id);
// 특정카테고리 정률 20% 할인 쿠폰
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 20,
targetScope: CouponTargetScope::CATEGORIES
);
// 쿠폰에 적용 카테고리 연결
$couponIssue->coupon->includedCategories()->attach([$category->id], ['type' => 'include']);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id],
);
// When
$result = $this->service->calculate($input);
// Then: 상품A만 20% 할인 = 4,000원, 상품B는 카테고리가 없어 할인 없음
$this->assertEquals(4000, $result->items[0]->productCouponDiscountAmount); // 상품A: 20,000 × 20%
$this->assertEquals(0, $result->items[1]->productCouponDiscountAmount); // 상품B: 할인 없음
$this->assertEquals(4000, $result->summary->productCouponDiscount);
}
/**
* 테스트 9: 할인 최대금액 제한
*
* 입력: 쿠폰: rate 50%, max_discount 5,000원
* 기대: 할인액 5,000원 초과 불가
*/
public function test_it_limits_discount_to_max_amount(): void
{
// Given: 상품 30,000원
[$product, $option] = $this->createProductWithOption(price: 30000);
// 전체상품 정률 50% 할인 쿠폰, 최대 할인 5,000원
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 50,
targetScope: CouponTargetScope::ALL,
maxDiscount: 5000
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id],
);
// When
$result = $this->service->calculate($input);
// Then: 50% = 15,000원이지만, 최대 할인 5,000원 적용
$this->assertEquals(5000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(5000, $result->summary->productCouponDiscount);
}
// ========================================
// Section 7.3: 배송비 계산 - 단일 정책 (14개 중 주요 6개)
// ========================================
/**
* 테스트용 배송정책을 생성합니다.
*
* 국가별 설정(ShippingPolicyCountrySetting) 구조로 생성합니다.
*
* @param ChargePolicyEnum $chargePolicy 배송비 부과정책
* @param int $baseFee 기본 배송비
* @param int|null $freeThreshold 무료배송 기준금액
* @param array|null $ranges 구간 설정
* @param bool $extraFeeEnabled 도서산간 추가배송비 사용여부
* @param array|null $extraFeeSettings 도서산간 추가배송비 설정
* @param bool $extraFeeMultiply 도서산간 추가배송비 수량비례 적용
* @param string $countryCode 국가코드
* @param string $currencyCode 통화코드
*/
protected function createShippingPolicy(
ChargePolicyEnum $chargePolicy = ChargePolicyEnum::FREE,
int $baseFee = 0,
?int $freeThreshold = null,
?array $ranges = null,
bool $extraFeeEnabled = false,
?array $extraFeeSettings = null,
bool $extraFeeMultiply = false,
string $countryCode = 'KR',
string $currencyCode = 'KRW',
?string $apiEndpoint = null,
?array $apiRequestFields = null,
?string $apiResponseFeeField = null,
?array $apiConfig = null,
): ShippingPolicy {
$policy = ShippingPolicy::create([
'name' => ['ko' => '테스트 배송정책', 'en' => 'Test Shipping Policy'],
'is_default' => false,
'is_active' => true,
]);
$policy->countrySettings()->create([
'country_code' => $countryCode,
'shipping_method' => 'parcel',
'currency_code' => $currencyCode,
'charge_policy' => $chargePolicy,
'base_fee' => $baseFee,
'free_threshold' => $freeThreshold,
'ranges' => $ranges,
'api_endpoint' => $apiEndpoint,
'api_request_fields' => $apiRequestFields,
'api_response_fee_field' => $apiResponseFeeField,
'api_config' => $apiConfig,
'extra_fee_enabled' => $extraFeeEnabled,
'extra_fee_settings' => $extraFeeSettings,
'extra_fee_multiply' => $extraFeeMultiply,
'is_active' => true,
]);
return $policy->load('countrySettings');
}
/**
* 테스트용 배송정책을 다국가 설정으로 생성합니다.
*
* @param array $countrySettingsData 국가별 설정 배열 [{country_code, charge_policy, base_fee, ...}, ...]
*/
protected function createMultiCountryShippingPolicy(array $countrySettingsData): ShippingPolicy
{
$policy = ShippingPolicy::create([
'name' => ['ko' => '테스트 다국가 배송정책', 'en' => 'Test Multi-Country Shipping Policy'],
'is_default' => false,
'is_active' => true,
]);
foreach ($countrySettingsData as $cs) {
$policy->countrySettings()->create(array_merge([
'shipping_method' => 'parcel',
'currency_code' => 'KRW',
'charge_policy' => ChargePolicyEnum::FREE,
'base_fee' => 0,
'free_threshold' => null,
'ranges' => null,
'extra_fee_enabled' => false,
'extra_fee_settings' => null,
'extra_fee_multiply' => false,
'is_active' => true,
], $cs));
}
return $policy->load('countrySettings');
}
/**
* 테스트용 상품과 옵션을 배송정책과 함께 생성합니다.
*
* @param int $price 상품 판매가
* @param ShippingPolicy $shippingPolicy 배송정책
* @return array [Product, ProductOption]
*/
protected function createProductWithShippingPolicy(
int $price,
ShippingPolicy $shippingPolicy
): array {
$product = ProductFactory::new()->create([
'tax_status' => 'taxable',
'selling_price' => $price,
'list_price' => $price,
'shipping_policy_id' => $shippingPolicy->id,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => 100,
'is_default' => true,
]);
return [$product, $option];
}
/**
* 무게/부피가 설정된 상품+옵션 생성
*
* @param int $price 상품 가격
* @param ShippingPolicy $shippingPolicy 배송정책
* @param float $weight 무게 (g — 상품 옵션의 저장 단위)
* @param float $volume 부피 (cm³ — 상품 옵션의 저장 단위)
* @return array [Product, ProductOption]
*/
protected function createProductWithDimensions(
int $price,
ShippingPolicy $shippingPolicy,
float $weight,
float $volume
): array {
$product = ProductFactory::new()->create([
'tax_status' => 'taxable',
'selling_price' => $price,
'list_price' => $price,
'shipping_policy_id' => $shippingPolicy->id,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => 100,
'is_default' => true,
'weight' => $weight,
'volume' => $volume,
]);
return [$product, $option];
}
/**
* 테스트 10: FREE 정책 - 무료 배송
*
* 입력: FREE 정책
* 기대: 배송비 0원
*/
public function test_it_calculates_free_shipping_policy(): void
{
// Given: FREE 배송정책
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FREE
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 배송비 0원
$this->assertEquals(0, $result->summary->totalShipping);
}
/**
* 테스트 11: FIXED 정책 - 고정 배송비
*
* 입력: FIXED, base_fee: 3,000원
* 기대: 배송비 3,000원
*/
public function test_it_calculates_fixed_shipping_policy(): void
{
// Given: FIXED 3,000원 배송정책
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 배송비 3,000원
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 테스트 12: CONDITIONAL_FREE 정책 - 무료배송 충족
*
* 입력: CONDITIONAL_FREE, base_fee: 3,000원, threshold: 50,000원, 그룹합계 60,000원
* 기대: 배송비 0원
*/
public function test_it_calculates_conditional_free_shipping_when_threshold_met(): void
{
// Given: CONDITIONAL_FREE (5만원 이상 무료, 미만 3,000원)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000
);
[$product, $option] = $this->createProductWithShippingPolicy(60000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 6만원 > 5만원 → 무료배송
$this->assertEquals(0, $result->summary->totalShipping);
}
/**
* 테스트 13: CONDITIONAL_FREE 정책 - 무료배송 미충족
*
* 입력: CONDITIONAL_FREE, base_fee: 3,000원, threshold: 50,000원, 그룹합계 30,000원
* 기대: 배송비 3,000원
*/
public function test_it_calculates_conditional_free_shipping_when_threshold_not_met(): void
{
// Given: CONDITIONAL_FREE (5만원 이상 무료, 미만 3,000원)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 3만원 < 5만원 → 배송비 3,000원
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 테스트 14: RANGE_AMOUNT 정책 - 금액 구간별 배송비
*
* 입력: RANGE_AMOUNT, tiers: [~2만:5천, ~4만:4천, ~∞:3천], 그룹합계 35,000원
* 기대: 배송비 4,000원
*/
public function test_it_calculates_range_amount_shipping_policy(): void
{
// Given: RANGE_AMOUNT 정책
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 20000, 'fee' => 5000],
['min' => 20000, 'max' => 40000, 'fee' => 4000],
['min' => 40000, 'max' => null, 'fee' => 3000],
],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(35000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 35,000원 → ~4만원 구간 → 4,000원
$this->assertEquals(4000, $result->summary->totalShipping);
}
/**
* 테스트 15: RANGE_QUANTITY 정책 - 수량 구간별 배송비
*
* 입력: RANGE_QUANTITY, tiers: [~2개:5천, ~5개:4천, ~∞:3천], 총 수량 4개
* 기대: 배송비 4,000원
*/
public function test_it_calculates_range_quantity_shipping_policy(): void
{
// Given: RANGE_QUANTITY 정책
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_QUANTITY,
ranges: [
'tiers' => [
['min' => 0, 'max' => 2, 'fee' => 5000],
['min' => 2, 'max' => 5, 'fee' => 4000],
['min' => 5, 'max' => null, 'fee' => 3000],
],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 4
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 4개 → ~5개 구간 → 4,000원
$this->assertEquals(4000, $result->summary->totalShipping);
}
/**
* 테스트 19: PER_QUANTITY 정책 - 수량당 배송비
*
* 입력: PER_QUANTITY, base_fee: 2,000원, unit_value: 2개, 수량 5개
* 기대: ceil(5/2) × 2,000 = 6,000원
*/
public function test_it_calculates_per_quantity_shipping_policy(): void
{
// Given: PER_QUANTITY (2개당 2,000원)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 2000,
ranges: ['unit_value' => 2]
);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 5
),
]
);
// When
$result = $this->service->calculate($input);
// Then: ceil(5/2) × 2,000 = 3 × 2,000 = 6,000원
$this->assertEquals(6000, $result->summary->totalShipping);
}
/**
* 테스트 16: RANGE_WEIGHT 정책 - 무게 구간별 배송비
*
* 입력: RANGE_WEIGHT, tiers: [~1kg:3천, ~3kg:5천, ~∞:8천], 무게 2.5kg
* 기대: 배송비 5,000원
*/
public function test_it_calculates_range_weight_shipping_policy(): void
{
// Given: RANGE_WEIGHT 정책 (배송정책 구간은 kg 단위)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 1, 'fee' => 3000], // ~1kg
['min' => 1, 'max' => 3, 'fee' => 5000], // ~3kg
['min' => 3, 'max' => null, 'fee' => 8000], // 3kg~
],
]
);
// 상품 옵션 무게는 g 저장 → 2,500g = 2.5kg → ~3kg 구간 → 5,000원
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2500, 1000);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 2.5kg → ~3kg 구간 → 5,000원
$this->assertEquals(5000, $result->summary->totalShipping);
}
/**
* 테스트 17: RANGE_VOLUME 정책 - 부피 구간별 배송비
*
* 입력: RANGE_VOLUME, tiers: [~5L:3천, ~10L:5천, ~∞:8천], 부피 7000cm³(=7L)
* 기대: 배송비 5,000원
*/
public function test_it_calculates_range_volume_shipping_policy(): void
{
// Given: RANGE_VOLUME 정책 (배송정책 구간은 L 단위)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME,
ranges: [
'tiers' => [
['min' => 0, 'max' => 5, 'fee' => 3000],
['min' => 5, 'max' => 10, 'fee' => 5000],
['min' => 10, 'max' => null, 'fee' => 8000],
],
]
);
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1000, 7000);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 7000cm³ = 7L → ~10L 구간 → 5,000원
$this->assertEquals(5000, $result->summary->totalShipping);
}
/**
* 테스트 18: RANGE_VOLUME_WEIGHT 정책 - 부피무게 구간별 배송비
*
* 입력: RANGE_VOLUME_WEIGHT, divisor: 6000, tiers: [~2kg:3천, ~5kg:5천, ~∞:8천]
* 부피 18000cm³ → 부피무게 3kg
* 기대: 배송비 5,000원
*/
public function test_it_calculates_range_volume_weight_shipping_policy(): void
{
// Given: RANGE_VOLUME_WEIGHT 정책 (배송정책 구간은 kg 단위)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME_WEIGHT,
ranges: [
'volume_weight_divisor' => 6000,
'tiers' => [
['min' => 0, 'max' => 2, 'fee' => 3000], // ~2kg
['min' => 2, 'max' => 5, 'fee' => 5000], // ~5kg
['min' => 5, 'max' => null, 'fee' => 8000], // 5kg~
],
]
);
// 실무게 1,000g(=1kg) vs 부피무게 18000cm³ / 6000 = 3kg → 3kg 채택 → ~5kg 구간 → 5,000원
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1000, 18000);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 부피무게 3kg → ~5kg 구간 → 5,000원
$this->assertEquals(5000, $result->summary->totalShipping);
}
/**
* 테스트 20: PER_WEIGHT 정책 - 무게당 배송비
*
* 입력: PER_WEIGHT, base_fee: 1000원, unit_value: 0.5kg, 무게 2kg
* 기대: ceil(2/0.5) × 1,000 = 4 × 1,000 = 4,000원
*/
public function test_it_calculates_per_weight_shipping_policy(): void
{
// Given: PER_WEIGHT (0.5kg당 1,000원)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 1000,
ranges: ['unit_value' => 0.5]
);
// 2,000g = 2kg
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2000, 1000);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: ceil(2/0.5) × 1,000 = 4 × 1,000 = 4,000원
$this->assertEquals(4000, $result->summary->totalShipping);
}
/**
* 테스트 21: PER_VOLUME 정책 - 부피당 배송비
*
* 입력: PER_VOLUME, base_fee: 500원, unit_value: 1L, 부피 3500cm³(=3.5L)
* 기대: ceil(3.5/1) × 500 = 4 × 500 = 2,000원
*/
public function test_it_calculates_per_volume_shipping_policy(): void
{
// Given: PER_VOLUME (1L당 500원 — 배송정책 단위값은 L)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_VOLUME,
baseFee: 500,
ranges: ['unit_value' => 1]
);
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1000, 3500);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: ceil(3.5/1) × 500 = 4 × 500 = 2,000원
$this->assertEquals(2000, $result->summary->totalShipping);
}
/**
* 테스트 22: PER_VOLUME_WEIGHT 정책 - 부피무게당 배송비
*
* 입력: PER_VOLUME_WEIGHT, base_fee: 2000원, divisor: 5000, unit_value: 1kg
* 부피 12000cm³ → 부피무게 2.4kg
* 기대: ceil(2.4/1) × 2,000 = 3 × 2,000 = 6,000원
*/
public function test_it_calculates_per_volume_weight_shipping_policy(): void
{
// Given: PER_VOLUME_WEIGHT (부피무게 1kg당 2,000원)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_VOLUME_WEIGHT,
baseFee: 2000,
ranges: ['volume_weight_divisor' => 5000, 'unit_value' => 1]
);
// 실무게 1,000g(=1kg) vs 부피 12000cm³ / 5000 = 부피무게 2.4kg → 2.4kg 채택
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1000, 12000);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: ceil(2.4/1) × 2,000 = 3 × 2,000 = 6,000원
$this->assertEquals(6000, $result->summary->totalShipping);
}
/**
* 테스트 23: PER_AMOUNT 정책 - 금액당 배송비
*
* 입력: PER_AMOUNT, base_fee: 1000원, unit_value: 10000원, 금액 35000원
* 기대: ceil(35000/10000) × 1,000 = 4 × 1,000 = 4,000원
*/
public function test_it_calculates_per_amount_shipping_policy(): void
{
// Given: PER_AMOUNT (10,000원당 1,000원)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_AMOUNT,
baseFee: 1000,
ranges: ['unit_value' => 10000]
);
[$product, $option] = $this->createProductWithShippingPolicy(35000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: ceil(35000/10000) × 1,000 = 4 × 1,000 = 4,000원
$this->assertEquals(4000, $result->summary->totalShipping);
}
/**
* 테스트 24: API 정책 배송비 계산 (api_endpoint 미설정)
*
* api_endpoint 미설정 시 base_fee 반환
*/
public function test_it_calculates_api_shipping_policy_without_endpoint(): void
{
// Given: API 정책 (api_endpoint 미설정 → base_fee 반환)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::API,
baseFee: 5000,
ranges: [] // api_endpoint 미설정
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: api_endpoint 미설정 → base_fee (5,000원)
$this->assertEquals(5000, $result->summary->totalShipping);
}
/**
* 테스트 24-2: API 정책 배송비 계산 (Mock API 성공 응답)
*
* 외부 API 호출을 Mock하여 배송비 계산 테스트
*/
public function test_it_calculates_api_shipping_policy_with_mock_response(): void
{
// Given: Mock API 응답 설정 (와일드카드 패턴)
Http::preventStrayRequests();
Http::fake([
'*' => Http::response([
'shipping_fee' => 7500,
'message' => 'success',
], 200),
]);
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::API,
baseFee: 5000, // fallback
apiEndpoint: 'https://shipping-api.example.com/calculate',
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: Mock API 응답 배송비 (7,500원)
$this->assertEquals(7500, $result->summary->totalShipping);
// API 호출 확인
Http::assertSent(function ($request) {
return str_contains($request->url(), 'shipping-api.example.com');
});
}
/**
* 테스트 24-3: API 정책 배송비 계산 (API 실패 시 fallback)
*
* API 호출 실패 시 base_fee로 fallback
*/
public function test_it_falls_back_to_base_fee_when_api_fails(): void
{
// Given: Mock API 실패 응답
Http::fake([
'https://shipping-api.example.com/calculate' => Http::response([
'error' => 'Service unavailable',
], 500),
]);
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::API,
baseFee: 5000, // fallback
apiEndpoint: 'https://shipping-api.example.com/calculate',
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: API 실패 → base_fee (5,000원)로 fallback
$this->assertEquals(5000, $result->summary->totalShipping);
}
/**
* 테스트 24-4: API 정책 배송비 계산 (API 타임아웃 시 fallback)
*
* API 호출 타임아웃 시 base_fee로 fallback
*/
public function test_it_falls_back_to_base_fee_when_api_timeout(): void
{
// Given: Mock API 타임아웃 (ConnectionException)
Http::fake([
'https://shipping-api.example.com/calculate' => function () {
throw new ConnectionException('Connection timeout');
},
]);
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::API,
baseFee: 5000, // fallback
apiEndpoint: 'https://shipping-api.example.com/calculate',
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: API 타임아웃 → base_fee (5,000원)로 fallback
$this->assertEquals(5000, $result->summary->totalShipping);
}
// ========================================
// Section 7.4: 배송비 계산 - 다중 정책 조합 (6개)
// ========================================
/**
* 테스트 25: 2개 정책 그룹 (FIXED + CONDITIONAL_FREE 미충족)
*
* 상품A,B: FIXED 3,000원 / 상품C: CONDITIONAL_FREE(미충족) 2,500원
* 기대: 총 5,500원
*/
public function test_it_calculates_two_policy_groups_fixed_and_conditional(): void
{
// Given: FIXED 정책 (3,000원)
$fixedPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
// CONDITIONAL_FREE 정책 (5만원 미만 시 2,500원)
$conditionalPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 2500,
freeThreshold: 50000
);
// 상품A, B: FIXED 정책 적용
[$productA, $optionA] = $this->createProductWithShippingPolicy(20000, $fixedPolicy);
[$productB, $optionB] = $this->createProductWithShippingPolicy(15000, $fixedPolicy);
// 상품C: CONDITIONAL_FREE 정책 적용 (금액 미충족)
[$productC, $optionC] = $this->createProductWithShippingPolicy(10000, $conditionalPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
new CalculationItem(
productId: $productC->id,
productOptionId: $optionC->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: FIXED 그룹 3,000원 + CONDITIONAL_FREE 그룹 2,500원 = 5,500원
$this->assertEquals(5500, $result->summary->totalShipping);
}
/**
* 테스트 26: 3개 정책 그룹 (FREE + FIXED + PER_QUANTITY)
*
* 상품A: FREE / 상품B: FIXED 3,000원 / 상품C,D: PER_QUANTITY 2,000원×2
* 기대: 총 7,000원 (0 + 3,000 + 4,000)
*/
public function test_it_calculates_three_policy_groups(): void
{
// Given: FREE 정책
$freePolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FREE
);
// FIXED 정책 (3,000원)
$fixedPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
// PER_QUANTITY 정책 (2개당 2,000원)
$perQuantityPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 2000,
ranges: ['unit_value' => 2]
);
[$productA, $optionA] = $this->createProductWithShippingPolicy(10000, $freePolicy);
[$productB, $optionB] = $this->createProductWithShippingPolicy(20000, $fixedPolicy);
[$productC, $optionC] = $this->createProductWithShippingPolicy(15000, $perQuantityPolicy);
[$productD, $optionD] = $this->createProductWithShippingPolicy(15000, $perQuantityPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
new CalculationItem(
productId: $productC->id,
productOptionId: $optionC->id,
quantity: 2
),
new CalculationItem(
productId: $productD->id,
productOptionId: $optionD->id,
quantity: 2
),
]
);
// When
$result = $this->service->calculate($input);
// Then: FREE 0원 + FIXED 3,000원 + PER_QUANTITY ceil(4/2)×2,000 = 4,000원 = 7,000원
$this->assertEquals(7000, $result->summary->totalShipping);
}
/**
* 테스트 27: 동일 정책 다른 설정 (CONDITIONAL_FREE)
*
* 상품A,B: CONDITIONAL_FREE(5만원 기준) / 상품C,D: CONDITIONAL_FREE(3만원 기준)
* 각 그룹별 독립 계산
*/
public function test_it_calculates_same_policy_different_settings(): void
{
// Given: CONDITIONAL_FREE 정책 (5만원 기준, 배송비 3,000원)
$policy50k = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000
);
// CONDITIONAL_FREE 정책 (3만원 기준, 배송비 2,500원)
$policy30k = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 2500,
freeThreshold: 30000
);
// 그룹1: 5만원 기준 미충족 (합계 40,000원)
[$productA, $optionA] = $this->createProductWithShippingPolicy(20000, $policy50k);
[$productB, $optionB] = $this->createProductWithShippingPolicy(20000, $policy50k);
// 그룹2: 3만원 기준 충족 (합계 35,000원)
[$productC, $optionC] = $this->createProductWithShippingPolicy(20000, $policy30k);
[$productD, $optionD] = $this->createProductWithShippingPolicy(15000, $policy30k);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
new CalculationItem(
productId: $productC->id,
productOptionId: $optionC->id,
quantity: 1
),
new CalculationItem(
productId: $productD->id,
productOptionId: $optionD->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 그룹1 미충족 3,000원 + 그룹2 충족 0원 = 3,000원
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 테스트 28: RANGE + PER 혼합
*
* 상품A: RANGE_AMOUNT / 상품B: PER_QUANTITY / 상품C: RANGE_QUANTITY
* 각 정책별 정확한 계산
*/
public function test_it_calculates_range_and_per_mixed_policies(): void
{
// Given: RANGE_AMOUNT 정책 (구간별)
$rangeAmountPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
baseFee: 0,
ranges: [
'tiers' => [
['min' => 0, 'max' => 20000, 'fee' => 5000],
['min' => 20000, 'max' => 40000, 'fee' => 4000],
['min' => 40000, 'max' => null, 'fee' => 3000],
],
]
);
// PER_QUANTITY 정책 (2개당 2,000원)
$perQuantityPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 2000,
ranges: ['unit_value' => 2]
);
// RANGE_QUANTITY 정책 (구간별)
$rangeQuantityPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_QUANTITY,
baseFee: 0,
ranges: [
'tiers' => [
['min' => 0, 'max' => 2, 'fee' => 5000],
['min' => 2, 'max' => 5, 'fee' => 4000],
['min' => 5, 'max' => null, 'fee' => 3000],
],
]
);
[$productA, $optionA] = $this->createProductWithShippingPolicy(25000, $rangeAmountPolicy);
[$productB, $optionB] = $this->createProductWithShippingPolicy(10000, $perQuantityPolicy);
[$productC, $optionC] = $this->createProductWithShippingPolicy(15000, $rangeQuantityPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1 // 금액 25,000원 → 2~4만 구간 → 4,000원
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 3 // ceil(3/2) = 2 × 2,000 = 4,000원
),
new CalculationItem(
productId: $productC->id,
productOptionId: $optionC->id,
quantity: 3 // 2~5개 구간 → 4,000원
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 4,000 + 4,000 + 4,000 = 12,000원
$this->assertEquals(12000, $result->summary->totalShipping);
}
/**
* 테스트 29: 무료+유료 혼합
*
* 상품A,B: FREE / 상품C: FIXED 3,000원 / 상품D: CONDITIONAL_FREE(충족)
* 기대: 총 3,000원
*/
public function test_it_calculates_free_and_paid_mixed(): void
{
// Given: FREE 정책
$freePolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FREE
);
// FIXED 정책 (3,000원)
$fixedPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
// CONDITIONAL_FREE 정책 (3만원 충족 시 무료)
$conditionalPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 2500,
freeThreshold: 30000
);
[$productA, $optionA] = $this->createProductWithShippingPolicy(10000, $freePolicy);
[$productB, $optionB] = $this->createProductWithShippingPolicy(10000, $freePolicy);
[$productC, $optionC] = $this->createProductWithShippingPolicy(20000, $fixedPolicy);
[$productD, $optionD] = $this->createProductWithShippingPolicy(40000, $conditionalPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
new CalculationItem(
productId: $productC->id,
productOptionId: $optionC->id,
quantity: 1
),
new CalculationItem(
productId: $productD->id,
productOptionId: $optionD->id,
quantity: 1 // 40,000원 ≥ 30,000원 → 무료
),
]
);
// When
$result = $this->service->calculate($input);
// Then: FREE 0원 + FIXED 3,000원 + CONDITIONAL_FREE(충족) 0원 = 3,000원
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 테스트 30: 4개 이상 정책 그룹
*
* 5개 상품, 각각 다른 배송정책 → 모든 정책 독립 계산 및 합산
*/
public function test_it_calculates_four_or_more_policy_groups(): void
{
// Given: 5개의 다른 배송정책
$policy1 = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FREE
);
$policy2 = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 2500
);
$policy3 = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 30000
);
$policy4 = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
baseFee: 0,
ranges: [
'tiers' => [
['min' => 0, 'max' => 30000, 'fee' => 4000],
['min' => 30000, 'max' => null, 'fee' => 2000],
],
]
);
$policy5 = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1500,
ranges: ['unit_value' => 3]
);
[$product1, $option1] = $this->createProductWithShippingPolicy(10000, $policy1);
[$product2, $option2] = $this->createProductWithShippingPolicy(15000, $policy2);
[$product3, $option3] = $this->createProductWithShippingPolicy(20000, $policy3); // 미충족
[$product4, $option4] = $this->createProductWithShippingPolicy(35000, $policy4); // 3만 이상
[$product5, $option5] = $this->createProductWithShippingPolicy(8000, $policy5);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
new CalculationItem(
productId: $product3->id,
productOptionId: $option3->id,
quantity: 1 // 20,000 < 30,000 → 3,000원
),
new CalculationItem(
productId: $product4->id,
productOptionId: $option4->id,
quantity: 1 // 35,000 ≥ 30,000 → 2,000원
),
new CalculationItem(
productId: $product5->id,
productOptionId: $option5->id,
quantity: 5 // ceil(5/3) = 2 × 1,500 = 3,000원
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 0 + 2,500 + 3,000 + 2,000 + 3,000 = 10,500원
$this->assertEquals(10500, $result->summary->totalShipping);
}
// ========================================
// Section 7.5: 배송비 안분 (3개)
// ========================================
/**
* 테스트 31: 동일 정책 2개 옵션 안분
*
* 정책A: 3,000원, 옵션 2개 (60,000원:40,000원 = 6:4 비율)
* 기대: 옵션1: 1,800원, 옵션2: 1,200원
*/
public function test_it_apportions_shipping_to_two_options_by_ratio(): void
{
// Given: FIXED 정책 (3,000원)
$fixedPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
// 옵션1: 60,000원 (60%)
[$product1, $option1] = $this->createProductWithShippingPolicy(60000, $fixedPolicy);
// 옵션2: 40,000원 (40%)
[$product2, $option2] = $this->createProductWithShippingPolicy(40000, $fixedPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 총 배송비 3,000원
$this->assertEquals(3000, $result->summary->totalShipping);
// 안분: 옵션1 60% = 1,800원, 옵션2 40% = 1,200원
$this->assertNotNull($result->items[0]->appliedShippingPolicy);
$this->assertNotNull($result->items[1]->appliedShippingPolicy);
$this->assertEquals(1800, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(1200, $result->items[1]->appliedShippingPolicy->shippingAmount);
}
/**
* 테스트 32: 동일 정책 3개 옵션 안분
*
* 정책A: 5,000원, 옵션 3개 (50,000원:30,000원:20,000원 = 5:3:2 비율)
* 기대: 2,500 + 1,500 + 1,000 = 5,000원
*/
public function test_it_apportions_shipping_to_three_options_by_ratio(): void
{
// Given: FIXED 정책 (5,000원)
$fixedPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 5000
);
// 옵션1: 50,000원 (50%)
[$product1, $option1] = $this->createProductWithShippingPolicy(50000, $fixedPolicy);
// 옵션2: 30,000원 (30%)
[$product2, $option2] = $this->createProductWithShippingPolicy(30000, $fixedPolicy);
// 옵션3: 20,000원 (20%)
[$product3, $option3] = $this->createProductWithShippingPolicy(20000, $fixedPolicy);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
new CalculationItem(
productId: $product3->id,
productOptionId: $option3->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 총 배송비 5,000원
$this->assertEquals(5000, $result->summary->totalShipping);
// 안분: 50% = 2,500원, 30% = 1,500원, 20% = 1,000원
$this->assertNotNull($result->items[0]->appliedShippingPolicy);
$this->assertNotNull($result->items[1]->appliedShippingPolicy);
$this->assertNotNull($result->items[2]->appliedShippingPolicy);
$this->assertEquals(2500, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(1500, $result->items[1]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(1000, $result->items[2]->appliedShippingPolicy->shippingAmount);
}
/**
* 테스트 33: 다중 정책 각각 안분
*
* 정책A: 3,000원 (옵션 2개), 정책B: 4,000원 (옵션 2개)
* 각 정책 내 독립 안분
*/
public function test_it_apportions_shipping_independently_per_policy(): void
{
// Given: 정책A (FIXED 3,000원)
$policyA = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
// 정책B (FIXED 4,000원)
$policyB = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 4000
);
// 정책A 그룹: 옵션1 60,000원 (60%), 옵션2 40,000원 (40%)
[$product1, $option1] = $this->createProductWithShippingPolicy(60000, $policyA);
[$product2, $option2] = $this->createProductWithShippingPolicy(40000, $policyA);
// 정책B 그룹: 옵션3 50,000원 (50%), 옵션4 50,000원 (50%)
[$product3, $option3] = $this->createProductWithShippingPolicy(50000, $policyB);
[$product4, $option4] = $this->createProductWithShippingPolicy(50000, $policyB);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
new CalculationItem(
productId: $product3->id,
productOptionId: $option3->id,
quantity: 1
),
new CalculationItem(
productId: $product4->id,
productOptionId: $option4->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 총 배송비 7,000원 (3,000 + 4,000)
$this->assertEquals(7000, $result->summary->totalShipping);
// 정책A 안분: 옵션1 1,800원, 옵션2 1,200원
$this->assertEquals(1800, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(1200, $result->items[1]->appliedShippingPolicy->shippingAmount);
// 정책B 안분: 옵션3 2,000원, 옵션4 2,000원
$this->assertEquals(2000, $result->items[2]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(2000, $result->items[3]->appliedShippingPolicy->shippingAmount);
}
// ========================================
// Section 7.5.1: 추가배송비(도서산간) 테스트 (10개)
// ========================================
/**
* 테스트 36-1: 도서산간 우편번호 매칭 시 추가배송비 부과
*
* 우편번호 63000 (제주도) → 추가배송비 3,000원
*/
public function test_it_applies_extra_shipping_fee_for_island_zipcode(): void
{
// Given: 추가배송비 활성화된 정책
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000], // 제주도
['zipcode' => '54*', 'fee' => 5000], // 울릉도
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// 배송지 주소 (제주도)
$shippingAddress = new ShippingAddress(zipcode: '63123');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 기본 3,000원 + 추가 3,000원 = 6,000원
$this->assertEquals(6000, $result->summary->totalShipping);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-2: 일반 지역 우편번호는 추가배송비 없음
*
* 우편번호 06000 (서울) → 추가배송비 없음
*/
public function test_it_does_not_apply_extra_fee_for_normal_zipcode(): void
{
// Given: 추가배송비 활성화된 정책
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000], // 제주도만
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// 배송지 주소 (서울)
$shippingAddress = new ShippingAddress(zipcode: '06123');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 기본 3,000원만, 추가배송비 없음
$this->assertEquals(3000, $result->summary->totalShipping);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(0, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-3: 배송지 주소 없으면 추가배송비 없음
*/
public function test_it_does_not_apply_extra_fee_without_shipping_address(): void
{
// Given: 추가배송비 활성화된 정책
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// 배송지 주소 없음
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 기본 3,000원만
$this->assertEquals(3000, $result->summary->totalShipping);
$this->assertEquals(0, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-4: 추가배송비 비활성화 시 적용 안 함
*/
public function test_it_does_not_apply_extra_fee_when_disabled(): void
{
// Given: 추가배송비 비활성화
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: false, // 비활성화
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63123'); // 제주도
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 기본 3,000원만, 추가배송비 없음
$this->assertEquals(3000, $result->summary->totalShipping);
$this->assertEquals(0, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-5: extra_fee_multiply=true + PER_QUANTITY 시 수량만큼 중복 부과
*
* PER_QUANTITY(2개당 1,000원), 수량 5개, 제주도 추가 3,000원
* 기본: ceil(5/2) × 1,000 = 3,000원
* 추가: 3,000 × ceil(5/2) = 9,000원 (multiply)
*/
public function test_it_multiplies_extra_fee_for_per_quantity_policy(): void
{
// Given: PER_QUANTITY + extra_fee_multiply=true
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 2],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
],
extraFeeMultiply: true
);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63123');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 5
),
],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 기본 3,000원 + 추가 9,000원 = 12,000원
$this->assertEquals(12000, $result->summary->totalShipping);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(9000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-6: extra_fee_multiply=false 시 1회만 부과
*
* PER_QUANTITY(2개당 1,000원), 수량 5개, 제주도 추가 3,000원
* 기본: ceil(5/2) × 1,000 = 3,000원
* 추가: 3,000원 (1회)
*/
public function test_it_does_not_multiply_extra_fee_when_multiply_disabled(): void
{
// Given: PER_QUANTITY + extra_fee_multiply=false
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 2],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
],
extraFeeMultiply: false
);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63123');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 5
),
],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 기본 3,000원 + 추가 3,000원 = 6,000원
$this->assertEquals(6000, $result->summary->totalShipping);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-7: FIXED 정책은 multiply와 무관하게 1회 부과
*/
public function test_it_applies_extra_fee_once_for_fixed_policy_even_with_multiply(): void
{
// Given: FIXED + extra_fee_multiply=true (무시됨)
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
],
extraFeeMultiply: true // FIXED에서는 무시됨
);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63123');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 5 // 수량과 무관
),
],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 기본 3,000원 + 추가 3,000원 = 6,000원 (multiply 무시)
$this->assertEquals(6000, $result->summary->totalShipping);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-8: 다중 정책 그룹에서 각각 추가배송비 적용
*/
public function test_it_applies_extra_fee_to_each_policy_group(): void
{
// Given: 정책A (제주 3,000원 추가)
$policyA = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
]
);
// 정책B (제주 5,000원 추가)
$policyB = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 4000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 5000],
]
);
[$product1, $option1] = $this->createProductWithShippingPolicy(30000, $policyA);
[$product2, $option2] = $this->createProductWithShippingPolicy(40000, $policyB);
$shippingAddress = new ShippingAddress(zipcode: '63123');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: (3,000+3,000) + (4,000+5,000) = 15,000원
$this->assertEquals(15000, $result->summary->totalShipping);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
$this->assertEquals(5000, $result->items[1]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-9: 정확한 우편번호 매칭 (와일드카드 없음)
*/
public function test_it_matches_exact_zipcode(): void
{
// Given: 정확한 우편번호 매칭
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63123', 'fee' => 2000], // 정확히 63123만
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// 매칭되는 우편번호
$shippingAddress1 = new ShippingAddress(zipcode: '63123');
$input1 = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress1
);
// When
$result1 = $this->service->calculate($input1);
// Then: 매칭 → 추가배송비 2,000원
$this->assertEquals(5000, $result1->summary->totalShipping);
$this->assertEquals(2000, $result1->items[0]->appliedShippingPolicy->extraShippingAmount);
// 매칭되지 않는 우편번호
$shippingAddress2 = new ShippingAddress(zipcode: '63124'); // 다른 우편번호
$input2 = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress2
);
$result2 = $this->service->calculate($input2);
// Then: 매칭 안 됨 → 추가배송비 0원
$this->assertEquals(3000, $result2->summary->totalShipping);
$this->assertEquals(0, $result2->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-4: 범위 패턴 매칭 (63000-63999)
*/
public function test_it_matches_range_pattern_zipcode(): void
{
// Given: 범위 패턴 우편번호 매칭
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63999', 'fee' => 4000], // 범위 패턴
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// 범위 내 우편번호
$shippingAddress1 = new ShippingAddress(zipcode: '63500');
$input1 = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress1
);
// When
$result1 = $this->service->calculate($input1);
// Then: 범위 내 → 추가배송비 4,000원
$this->assertEquals(7000, $result1->summary->totalShipping);
$this->assertEquals(4000, $result1->items[0]->appliedShippingPolicy->extraShippingAmount);
// 범위 밖 우편번호 (경계값 테스트)
$shippingAddress2 = new ShippingAddress(zipcode: '62999'); // 범위 미만
$input2 = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress2
);
$result2 = $this->service->calculate($input2);
// Then: 범위 밖 → 추가배송비 0원
$this->assertEquals(3000, $result2->summary->totalShipping);
$this->assertEquals(0, $result2->items[0]->appliedShippingPolicy->extraShippingAmount);
// 범위 끝 경계값 (64000 - 범위 초과)
$shippingAddress3 = new ShippingAddress(zipcode: '64000');
$input3 = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress3
);
$result3 = $this->service->calculate($input3);
// Then: 범위 밖 → 추가배송비 0원
$this->assertEquals(3000, $result3->summary->totalShipping);
$this->assertEquals(0, $result3->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-10: 첫 번째 매칭되는 패턴 적용
*/
public function test_it_applies_first_matching_pattern(): void
{
// Given: 여러 패턴 (첫 번째 매칭 적용)
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '631*', 'fee' => 2000], // 더 구체적
['zipcode' => '63*', 'fee' => 3000], // 더 일반적
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63123'); // 631*에 먼저 매칭
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 첫 번째 패턴 631* 적용 → 2,000원
$this->assertEquals(5000, $result->summary->totalShipping);
$this->assertEquals(2000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
// ========================================
// Section 7.5.1.1: 정책 유형별 도서산간 추가배송비 테스트
// ========================================
/**
* 테스트 36-11a: CONDITIONAL_FREE 정책 + 도서산간
*
* 조건 미충족: 기본 5,000원 + 추가 3,000원 = 8,000원
*/
public function test_it_applies_extra_fee_with_conditional_free_not_met(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 5000,
freeThreshold: 50000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63100');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 기본 5,000원 + 추가 3,000원 = 8,000원
$this->assertEquals(8000, $result->summary->totalShipping);
$this->assertEquals(5000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-11b: CONDITIONAL_FREE 정책 + 조건 충족 + 도서산간
*
* 조건 충족: 기본 0원 + 추가 3,000원 = 3,000원
*/
public function test_it_applies_extra_fee_with_conditional_free_met(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 5000,
freeThreshold: 50000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(60000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63100');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 기본 0원 (무료배송 충족) + 추가 3,000원 = 3,000원
$this->assertEquals(3000, $result->summary->totalShipping);
$this->assertEquals(0, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-11c: RANGE_AMOUNT 정책 + 도서산간
*
* 30,000원 상품 → 구간 매칭 → 기본 5,000원 + 추가 3,000원
*/
public function test_it_applies_extra_fee_with_range_amount(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
ranges: [
'type' => 'amount',
'tiers' => [
['min' => 0, 'max' => 20000, 'fee' => 3000],
['min' => 20001, 'max' => 50000, 'fee' => 5000],
['min' => 50001, 'max' => null, 'fee' => 8000],
],
],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63200');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 기본 5,000원 (30,000원 구간) + 추가 3,000원 = 8,000원
$this->assertEquals(8000, $result->summary->totalShipping);
$this->assertEquals(5000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-11d: RANGE_QUANTITY 정책 + 도서산간
*
* 수량 3개 → 구간 매칭 → 기본 5,000원 + 추가 3,000원
*/
public function test_it_applies_extra_fee_with_range_quantity(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_QUANTITY,
ranges: [
'type' => 'quantity',
'tiers' => [
['min' => 1, 'max' => 2, 'fee' => 3000],
['min' => 3, 'max' => 5, 'fee' => 5000],
['min' => 6, 'max' => null, 'fee' => 8000],
],
],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63500');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 3
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 기본 5,000원 (3~5개 구간) + 추가 3,000원 = 8,000원
$this->assertEquals(8000, $result->summary->totalShipping);
$this->assertEquals(5000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-11e: RANGE_WEIGHT 정책 + 도서산간
*
* 무게 2,500g(=2.5kg) → 구간 매칭 → 기본 5,000원 + 추가 3,000원
*/
public function test_it_applies_extra_fee_with_range_weight(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_WEIGHT,
ranges: [
'type' => 'weight',
'tiers' => [
['min' => 0, 'max' => 1, 'fee' => 3000],
['min' => 1, 'max' => 3, 'fee' => 5000],
['min' => 3, 'max' => null, 'fee' => 8000],
],
],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithDimensions(10000, $policy, weight: 2500, volume: 0);
$shippingAddress = new ShippingAddress(zipcode: '63100');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 기본 5,000원 (1~3kg 구간) + 추가 3,000원 = 8,000원
$this->assertEquals(8000, $result->summary->totalShipping);
$this->assertEquals(5000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-11f: RANGE_VOLUME 정책 + 도서산간 (이번 버그 시나리오)
*
* 부피 30,000cm³ × 2개 = 60L → 구간 매칭 → 기본 10,000원 + 추가 3,000원
*/
public function test_it_applies_extra_fee_with_range_volume(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME,
ranges: [
'type' => 'volume',
'tiers' => [
['min' => 0, 'max' => 50, 'fee' => 5000],
['min' => 50, 'max' => 100, 'fee' => 10000],
['min' => 100, 'max' => null, 'fee' => 20000],
],
],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithDimensions(27000, $policy, weight: 500, volume: 30000);
$shippingAddress = new ShippingAddress(zipcode: '63100');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 2
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 부피 60,000cm³(30,000×2) = 60L → ~100L 구간 → 기본 10,000원 + 추가 3,000원 = 13,000원
$this->assertEquals(13000, $result->summary->totalShipping);
$this->assertEquals(10000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-11g: PER_WEIGHT 정책 + 도서산간 + multiply=true
*
* 무게 1,500g(=1.5kg) × 4개 = 6kg, 단위 2kg당 2,000원
* 기본: ceil(6/2)×2,000=6,000원
* 추가: multiply 는 기본 배송비와 같은 배송 단위 수(3) 기준 → 3×3,000=9,000원
*/
public function test_it_applies_extra_fee_with_per_weight_multiply(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 2],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
],
extraFeeMultiply: true
);
[$product, $option] = $this->createProductWithDimensions(10000, $policy, weight: 1500, volume: 0);
$shippingAddress = new ShippingAddress(zipcode: '63200');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 4
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 기본 ceil(6kg/2kg)=3단위 × 2,000 = 6,000원 + 추가 3단위 × 3,000 = 9,000원 = 15,000원
$this->assertEquals(15000, $result->summary->totalShipping);
$this->assertEquals(6000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(9000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-11h: PER_VOLUME 정책 + 도서산간
*
* 부피 50,000cm³(=50L), 단위 20L당 1,500원, 추가배송비 3,000원(1회)
*/
public function test_it_applies_extra_fee_with_per_volume(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_VOLUME,
baseFee: 1500,
ranges: ['unit_value' => 20],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithDimensions(10000, $policy, weight: 0, volume: 50000);
$shippingAddress = new ShippingAddress(zipcode: '63500');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 기본 ceil(50L/20L)×1,500=4,500원 + 추가 3,000원(1회) = 7,500원
$this->assertEquals(7500, $result->summary->totalShipping);
$this->assertEquals(4500, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-11i: PER_AMOUNT 정책 + 도서산간
*
* 금액 30,000원, 단위 10,000원당 1,000원, 추가배송비 3,000원(1회)
*/
public function test_it_applies_extra_fee_with_per_amount(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_AMOUNT,
baseFee: 1000,
ranges: ['unit_value' => 10000],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63100');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 기본 ceil(30000/10000)×1,000=3,000원 + 추가 3,000원(1회) = 6,000원
$this->assertEquals(6000, $result->summary->totalShipping);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 테스트 36-11j: FREE 정책 + 도서산간
*
* 기본 0원이지만 추가배송비 3,000원만 부과
*/
public function test_it_applies_extra_fee_with_free_policy(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FREE,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
$shippingAddress = new ShippingAddress(zipcode: '63100');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
shippingAddress: $shippingAddress
);
$result = $this->service->calculate($input);
// Then: 기본 0원 + 추가 3,000원 = 3,000원
$this->assertEquals(3000, $result->summary->totalShipping);
$this->assertEquals(0, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
// ========================================
// Section 7.5.2: 추가배송비+쿠폰 복합 테스트 (4개)
// ========================================
/**
* 테스트 36-11: 배송비 쿠폰이 기본+추가배송비 모두 할인
*
* 기본 3,000원 + 추가 3,000원 = 6,000원
* 배송비 쿠폰 100% 할인 → 최종 0원
*/
public function test_it_applies_shipping_coupon_to_base_and_extra_shipping(): void
{
// Given: 추가배송비 활성화 정책
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// 배송비 100% 쿠폰
$shippingCoupon = $this->createShippingCouponWithIssue(
discountType: CouponDiscountType::RATE,
discountValue: 100
);
$shippingAddress = new ShippingAddress(zipcode: '63123');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$shippingCoupon->id],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 배송비 쿠폰이 기본+추가 모두 할인
// totalShipping = 할인 전 총 배송비, shippingDiscount = 할인금액
$this->assertEquals(6000, $result->summary->totalShipping);
$this->assertEquals(6000, $result->summary->shippingDiscount);
// 최종 배송비 = totalShipping - shippingDiscount = 0
}
/**
* 테스트 36-12: 배송비 쿠폰 정액 할인이 추가배송비까지 적용
*
* 기본 3,000원 + 추가 3,000원 = 6,000원
* 배송비 쿠폰 4,000원 정액 할인 → 최종 2,000원
*/
public function test_it_applies_fixed_shipping_coupon_to_total_shipping(): void
{
// Given: 추가배송비 활성화 정책
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// 배송비 4,000원 정액 쿠폰
$shippingCoupon = $this->createShippingCouponWithIssue(
discountType: CouponDiscountType::FIXED,
discountValue: 4000
);
$shippingAddress = new ShippingAddress(zipcode: '63123');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$shippingCoupon->id],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 할인 전 6,000원, 쿠폰 4,000원 할인
// totalShipping = 할인 전 총 배송비, shippingDiscount = 할인금액
$this->assertEquals(6000, $result->summary->totalShipping);
$this->assertEquals(4000, $result->summary->shippingDiscount);
// 최종 배송비 = totalShipping - shippingDiscount = 2,000원
}
/**
* 테스트 36-13: 추가배송비 없을 때 배송비 쿠폰은 기본만 할인
*
* 기본 3,000원 (추가 없음)
* 배송비 쿠폰 100% 할인 → 최종 0원
*/
public function test_it_applies_shipping_coupon_only_to_base_when_no_extra(): void
{
// Given: 추가배송비 활성화 정책 (일반 지역)
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000], // 제주만
]
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// 배송비 100% 쿠폰
$shippingCoupon = $this->createShippingCouponWithIssue(
discountType: CouponDiscountType::RATE,
discountValue: 100
);
$shippingAddress = new ShippingAddress(zipcode: '06123'); // 서울 (추가배송비 없음)
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$shippingCoupon->id],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 기본 3,000원만 할인
// totalShipping = 할인 전 총 배송비, shippingDiscount = 할인금액
$this->assertEquals(3000, $result->summary->totalShipping);
$this->assertEquals(3000, $result->summary->shippingDiscount);
// 최종 배송비 = totalShipping - shippingDiscount = 0원
}
/**
* 테스트 36-14: 다중 정책 그룹에서 추가배송비+쿠폰 적용
*
* 정책A: 기본 3,000 + 추가 3,000 = 6,000원
* 정책B: 기본 4,000 + 추가 5,000 = 9,000원
* 총 배송비: 15,000원
* 배송비 쿠폰 50% 할인 → 7,500원
*/
public function test_it_applies_shipping_coupon_to_multiple_policy_groups_with_extra(): void
{
// Given: 정책A
$policyA = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
]
);
// 정책B
$policyB = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 4000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 5000],
]
);
[$product1, $option1] = $this->createProductWithShippingPolicy(30000, $policyA);
[$product2, $option2] = $this->createProductWithShippingPolicy(40000, $policyB);
// 배송비 50% 쿠폰
$shippingCoupon = $this->createShippingCouponWithIssue(
discountType: CouponDiscountType::RATE,
discountValue: 50
);
$shippingAddress = new ShippingAddress(zipcode: '63123');
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
],
couponIssueIds: [$shippingCoupon->id],
shippingAddress: $shippingAddress
);
// When
$result = $this->service->calculate($input);
// Then: 15,000원의 50% = 7,500원 할인
// totalShipping = 할인 전 총 배송비, shippingDiscount = 할인금액
$this->assertEquals(15000, $result->summary->totalShipping);
$this->assertEquals(7500, $result->summary->shippingDiscount);
// 최종 배송비 = totalShipping - shippingDiscount = 7,500원
}
// ========================================
// Section 7.5.3: 국가별 배송비 분기 테스트 (6개)
// ========================================
/**
* 테스트: 배송 주소의 국가코드에 따라 해당 국가 설정의 배송비 적용
*
* 동일 정책에 KR=고정 3,000원, US=고정 15,000원 설정
* KR 주소 → 3,000원, US 주소 → 15,000원
*/
public function test_shipping_fee_uses_country_code_from_address(): void
{
// Given: KR=고정 3,000원, US=고정 15,000원 다국가 정책
$policy = $this->createMultiCountryShippingPolicy([
[
'country_code' => 'KR',
'charge_policy' => ChargePolicyEnum::FIXED,
'base_fee' => 3000,
'currency_code' => 'KRW',
],
[
'country_code' => 'US',
'charge_policy' => ChargePolicyEnum::FIXED,
'base_fee' => 15000,
'currency_code' => 'KRW',
],
]);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// When: KR 주소로 계산
$resultKR = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
shippingAddress: new ShippingAddress(countryCode: 'KR'),
));
// When: US 주소로 계산
$resultUS = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
shippingAddress: new ShippingAddress(countryCode: 'US'),
));
// Then: KR → 3,000원
$this->assertEquals(3000, $resultKR->summary->totalShipping);
$this->assertEquals('KR', $resultKR->items[0]->appliedShippingPolicy->countryCode);
// Then: US → 15,000원
$this->assertEquals(15000, $resultUS->summary->totalShipping);
$this->assertEquals('US', $resultUS->items[0]->appliedShippingPolicy->countryCode);
}
/**
* 테스트: 국가코드 null일 때 KR 기본값 사용
*
* ShippingAddress의 countryCode 기본값은 'KR'이므로
* 명시적으로 null을 전달해도 KR 설정이 적용됨
*/
public function test_shipping_fee_falls_back_to_kr_when_no_country_code(): void
{
// Given: KR=고정 3,000원 정책
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
countryCode: 'KR',
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// When: countryCode 없이 계산 (기본값 KR)
$result = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
));
// Then: KR 설정 적용 → 3,000원
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 테스트: 매칭되는 국가 설정이 없는 경우 배송비 0원 (skip)
*
* KR만 설정된 정책에 US 주소로 요청 → 매칭 없음 → 배송비 0원
*/
public function test_shipping_fee_skips_policy_without_matching_country(): void
{
// Given: KR만 설정된 정책
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
countryCode: 'KR',
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// When: US 주소로 계산 (KR 설정만 있으므로 매칭 안 됨)
$result = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
shippingAddress: new ShippingAddress(countryCode: 'US'),
));
// Then: 매칭 국가설정 없음 → 배송비 0원
$this->assertEquals(0, $result->summary->totalShipping);
}
/**
* 테스트: 도서산간 추가배송비는 KR 주소에서만 적용
*
* KR+US 다국가 정책에서 KR에만 도서산간 설정
* KR 주소+도서산간 우편번호 → 추가배송비 적용
* US 주소 → 추가배송비 미적용
*/
public function test_extra_fee_only_applies_to_kr_address(): void
{
// Given: KR=고정 3,000원+도서산간 5,000원, US=고정 15,000원
$policy = $this->createMultiCountryShippingPolicy([
[
'country_code' => 'KR',
'charge_policy' => ChargePolicyEnum::FIXED,
'base_fee' => 3000,
'currency_code' => 'KRW',
'extra_fee_enabled' => true,
'extra_fee_settings' => [
['zipcode' => '63000-63644', 'fee' => 5000],
],
'extra_fee_multiply' => false,
],
[
'country_code' => 'US',
'charge_policy' => ChargePolicyEnum::FIXED,
'base_fee' => 15000,
'currency_code' => 'KRW',
],
]);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// When: KR 제주도 주소 (도서산간)
$resultKR = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '63123'),
));
// When: US 주소 (도서산간 해당 없음)
$resultUS = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
shippingAddress: new ShippingAddress(countryCode: 'US'),
));
// Then: KR → 기본 3,000 + 도서산간 5,000 = 8,000원
$this->assertEquals(3000, $resultKR->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(5000, $resultKR->items[0]->appliedShippingPolicy->extraShippingAmount);
$this->assertEquals(8000, $resultKR->summary->totalShipping);
// Then: US → 기본 15,000원, 도서산간 0원
$this->assertEquals(15000, $resultUS->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(0, $resultUS->items[0]->appliedShippingPolicy->extraShippingAmount);
$this->assertEquals(15000, $resultUS->summary->totalShipping);
}
/**
* 테스트: AppliedShippingPolicy 스냅샷에 국가별 설정 정보 포함
*/
public function test_policy_snapshot_includes_country_setting(): void
{
// Given: KR=고정 3,000원
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
countryCode: 'KR',
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// When
$result = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
shippingAddress: new ShippingAddress(countryCode: 'KR'),
));
// Then: 스냅샷에 country_code 포함
$appliedPolicy = $result->items[0]->appliedShippingPolicy;
$this->assertNotNull($appliedPolicy);
$this->assertEquals('KR', $appliedPolicy->countryCode);
$this->assertEquals('fixed', $appliedPolicy->chargePolicy);
$this->assertNotEmpty($appliedPolicy->policySnapshot);
}
/**
* 테스트: 다국가 정책에서 국가별 다른 부과정책 적용
*
* KR=조건부무료(5만원 이상 무료, 미만 3,000원)
* US=구간별(무게 기준)
*/
public function test_different_charge_policy_per_country(): void
{
// Given: KR=조건부무료, US=고정 20,000원
$policy = $this->createMultiCountryShippingPolicy([
[
'country_code' => 'KR',
'charge_policy' => ChargePolicyEnum::CONDITIONAL_FREE,
'base_fee' => 3000,
'free_threshold' => 50000,
'currency_code' => 'KRW',
],
[
'country_code' => 'US',
'charge_policy' => ChargePolicyEnum::FIXED,
'base_fee' => 20000,
'currency_code' => 'KRW',
],
]);
// 상품 30,000원 (5만원 미만)
[$product, $option] = $this->createProductWithShippingPolicy(30000, $policy);
// When: KR 주소 (5만원 미만이므로 배송비 부과)
$resultKR = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
shippingAddress: new ShippingAddress(countryCode: 'KR'),
));
// When: US 주소 (고정 20,000원)
$resultUS = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
shippingAddress: new ShippingAddress(countryCode: 'US'),
));
// Then: KR → 조건부무료에서 미달이므로 3,000원
$this->assertEquals(3000, $resultKR->summary->totalShipping);
// Then: US → 고정 20,000원
$this->assertEquals(20000, $resultUS->summary->totalShipping);
// When: KR 5만원 이상 → 무료
$resultKRFree = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2)],
shippingAddress: new ShippingAddress(countryCode: 'KR'),
));
// Then: KR → 60,000원으로 5만원 이상 → 무료
$this->assertEquals(0, $resultKRFree->summary->totalShipping);
}
// ========================================
// Section 7.6: 배송비 쿠폰 적용 (3개)
// ========================================
/**
* 배송비 쿠폰 생성 헬퍼
*
* @param CouponDiscountType $discountType 할인 타입
* @param float $discountValue 할인 값
* @param float|null $maxDiscount 최대 할인금액
*/
protected function createShippingCouponWithIssue(
CouponDiscountType $discountType,
float $discountValue,
?float $maxDiscount = null
): CouponIssue {
$user = User::factory()->create();
$coupon = Coupon::create([
'code' => 'SHIP'.uniqid(),
'name' => ['ko' => '배송비 쿠폰', 'en' => 'Shipping Coupon'],
'target_type' => CouponTargetType::SHIPPING_FEE,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'max_discount' => $maxDiscount,
'min_order_amount' => 0,
// 정상 조합(배송비쿠폰 + 다른 쿠폰 동시 적용) 검증용 헬퍼 — 중복 허용 명시.
// 미설정 시 DB 기본값 is_combinable=false 로 not_combinable 위반 → 할인 제외 회귀.
'is_combinable' => true,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
return CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
}
/**
* 테스트 34: 배송비 전액 할인 (쿠폰 할인액 > 배송비)
*
* 쿠폰: shipping_fee, fixed 5,000원, 배송비 3,000원
* 기대: 배송비 0원 (초과분 무시)
*/
public function test_it_applies_shipping_coupon_full_discount(): void
{
// Given: FIXED 정책 (3,000원)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
[$product, $option] = $this->createProductWithShippingPolicy(50000, $shippingPolicy);
// 배송비 쿠폰: 5,000원 할인 (배송비보다 큼)
$couponIssue = $this->createShippingCouponWithIssue(
discountType: CouponDiscountType::FIXED,
discountValue: 5000
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 배송비 3,000원 - 쿠폰 5,000원 = 0원 (음수 안 됨)
$this->assertEquals(3000, $result->summary->totalShipping);
$this->assertEquals(3000, $result->summary->shippingDiscount); // 실제 할인액은 배송비까지만
}
/**
* 테스트 35: 배송비 정률 할인
*
* 쿠폰: shipping_fee, rate 50%, 배송비 4,000원
* 기대: 배송비 할인 2,000원
*/
public function test_it_applies_shipping_coupon_rate_discount(): void
{
// Given: FIXED 정책 (4,000원)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 4000
);
[$product, $option] = $this->createProductWithShippingPolicy(50000, $shippingPolicy);
// 배송비 쿠폰: 50% 할인
$couponIssue = $this->createShippingCouponWithIssue(
discountType: CouponDiscountType::RATE,
discountValue: 50
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 배송비 4,000원 × 50% = 2,000원 할인
$this->assertEquals(4000, $result->summary->totalShipping);
$this->assertEquals(2000, $result->summary->shippingDiscount);
}
/**
* 테스트 36: 배송비 부분 할인
*
* 쿠폰: shipping_fee, fixed 1,000원, 배송비 3,000원
* 기대: 배송비 할인 1,000원 (실 배송비 2,000원)
*/
public function test_it_applies_shipping_coupon_partial_discount(): void
{
// Given: FIXED 정책 (3,000원)
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
[$product, $option] = $this->createProductWithShippingPolicy(50000, $shippingPolicy);
// 배송비 쿠폰: 1,000원 할인
$couponIssue = $this->createShippingCouponWithIssue(
discountType: CouponDiscountType::FIXED,
discountValue: 1000
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 배송비 3,000원 - 쿠폰 1,000원 = 2,000원 할인 후 남은 배송비
$this->assertEquals(3000, $result->summary->totalShipping);
$this->assertEquals(1000, $result->summary->shippingDiscount);
}
// ========================================
// Section 7.7: 주문금액 쿠폰 적용 (4개)
// ========================================
/**
* 주문금액 쿠폰 생성 헬퍼
*
* @param CouponDiscountType $discountType 할인 타입
* @param float $discountValue 할인 값
* @param float|null $maxDiscount 최대 할인금액
*/
protected function createOrderCouponWithIssue(
CouponDiscountType $discountType,
float $discountValue,
?float $maxDiscount = null
): CouponIssue {
$user = User::factory()->create();
$coupon = Coupon::create([
'code' => 'ORDER'.uniqid(),
'name' => ['ko' => '주문금액 쿠폰', 'en' => 'Order Coupon'],
'target_type' => CouponTargetType::ORDER_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'max_discount' => $maxDiscount,
'min_order_amount' => 0,
// 정상 조합(상품쿠폰 + 주문쿠폰 동시 적용) 검증용 헬퍼 — 중복 허용 명시.
// 미설정 시 DB 기본값 is_combinable=false 가 적용되어 not_combinable 위반으로
// 할인이 제외되는 회귀가 발생한다 (A15 소프트 제외/MP06).
'is_combinable' => true,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
return CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
}
/**
* 상품금액 쿠폰 생성 헬퍼
*
* @param CouponTargetScope $targetScope 적용 범위
* @param CouponDiscountType $discountType 할인 타입
* @param float $discountValue 할인 값
* @param float|null $maxDiscount 최대 할인금액
* @param array $targetIds 대상 상품/카테고리 ID (선택)
*/
protected function createProductCouponWithIssue(
CouponTargetScope $targetScope,
CouponDiscountType $discountType,
float $discountValue,
?float $maxDiscount = null,
array $targetIds = []
): CouponIssue {
$user = User::factory()->create();
$coupon = Coupon::create([
'code' => 'PRODUCT'.uniqid(),
'name' => ['ko' => '상품금액 쿠폰', 'en' => 'Product Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => $targetScope,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'discount_max_amount' => $maxDiscount,
'min_order_amount' => 0,
'is_combinable' => true,
'valid_from' => now()->subDay(),
'valid_to' => now()->addDay(),
]);
// 특정 상품/카테고리 지정 (피벗 테이블)
if (! empty($targetIds)) {
if ($targetScope === CouponTargetScope::PRODUCTS) {
$pivotData = array_fill_keys($targetIds, ['type' => 'include']);
$coupon->products()->attach($pivotData);
} elseif ($targetScope === CouponTargetScope::CATEGORIES) {
$pivotData = array_fill_keys($targetIds, ['type' => 'include']);
$coupon->categories()->attach($pivotData);
}
}
return CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expired_at' => now()->addDay(),
]);
}
/**
* 테스트 37: 주문금액 정액 할인 및 안분
*
* 쿠폰: order_amount, fixed 5,000원
* 주문금액 100,000원 (옵션1: 60,000, 옵션2: 40,000)
* 기대: 옵션별 금액 비율로 5,000원 안분 (3,000 + 2,000)
*/
public function test_it_applies_order_coupon_fixed_discount_with_apportionment(): void
{
// Given: 두 상품
[$product1, $option1] = $this->createProductWithOption(60000);
[$product2, $option2] = $this->createProductWithOption(40000);
// 주문금액 쿠폰: 5,000원 할인
$couponIssue = $this->createOrderCouponWithIssue(
discountType: CouponDiscountType::FIXED,
discountValue: 5000
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 총 할인 5,000원
$this->assertEquals(5000, $result->summary->orderCouponDiscount);
// 안분: 옵션1 60% = 3,000원, 옵션2 40% = 2,000원
$this->assertEquals(3000, $result->items[0]->orderCouponDiscountShare);
$this->assertEquals(2000, $result->items[1]->orderCouponDiscountShare);
}
/**
* 테스트 38: 주문금액 정률 할인 및 안분
*
* 쿠폰: order_amount, rate 10%
* 주문금액 100,000원
* 기대: 10,000원 할인, 옵션별 금액 비율로 안분
*/
public function test_it_applies_order_coupon_rate_discount_with_apportionment(): void
{
// Given: 두 상품
[$product1, $option1] = $this->createProductWithOption(50000);
[$product2, $option2] = $this->createProductWithOption(50000);
// 주문금액 쿠폰: 10% 할인
$couponIssue = $this->createOrderCouponWithIssue(
discountType: CouponDiscountType::RATE,
discountValue: 10
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 100,000원 × 10% = 10,000원 할인
$this->assertEquals(10000, $result->summary->orderCouponDiscount);
// 안분: 50% 씩 → 5,000원 씩
$this->assertEquals(5000, $result->items[0]->orderCouponDiscountShare);
$this->assertEquals(5000, $result->items[1]->orderCouponDiscountShare);
}
/**
* 테스트 39: 주문할인 안분 정확성 (끝전 처리)
*
* 주문금액 100,000원, 할인 3,333원, 옵션 3개 (균등 분할 시 나누어 떨어지지 않음)
* 기대: 끝전 처리로 합계 정확히 3,333원
*/
public function test_it_handles_order_discount_apportionment_remainder(): void
{
// Given: 세 상품 (동일 금액)
[$product1, $option1] = $this->createProductWithOption(33333);
[$product2, $option2] = $this->createProductWithOption(33333);
[$product3, $option3] = $this->createProductWithOption(33334);
// 주문금액 쿠폰: 3,333원 할인
$couponIssue = $this->createOrderCouponWithIssue(
discountType: CouponDiscountType::FIXED,
discountValue: 3333
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
new CalculationItem(
productId: $product3->id,
productOptionId: $option3->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 총 할인 3,333원
$this->assertEquals(3333, $result->summary->orderCouponDiscount);
// 안분 합계가 정확히 3,333원이어야 함 (끝전 처리)
$totalApportioned = $result->items[0]->orderCouponDiscountShare
+ $result->items[1]->orderCouponDiscountShare
+ $result->items[2]->orderCouponDiscountShare;
$this->assertEquals(3333, $totalApportioned);
}
/**
* 테스트 40: 할인 후 금액 > 0 보장 (음수 방지)
*
* 할인액이 상품가보다 클 때 최소 0원, 음수 방지
*/
public function test_it_prevents_negative_final_amount(): void
{
// Given: 작은 금액 상품
[$product, $option] = $this->createProductWithOption(1000);
// 주문금액 쿠폰: 5,000원 할인 (상품가보다 큼)
$couponIssue = $this->createOrderCouponWithIssue(
discountType: CouponDiscountType::FIXED,
discountValue: 5000
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 최종 금액이 음수가 아니어야 함
$this->assertGreaterThanOrEqual(0, $result->summary->finalAmount);
// 할인액이 상품가를 초과하지 않아야 함 (초과분 무시)
$this->assertLessThanOrEqual($result->summary->subtotal, $result->summary->orderCouponDiscount);
}
// ========================================
// Section 7.9: 과세/면세 분류 (3개)
// ========================================
/**
* 과세 상태 지정 상품 생성 헬퍼
*
* @param int $price 상품 가격
* @param ProductTaxStatus $taxStatus 과세 상태
* @return array [Product, ProductOption]
*/
protected function createProductWithTaxStatus(
int $price,
ProductTaxStatus $taxStatus
): array {
$product = ProductFactory::new()->create([
'tax_status' => $taxStatus,
'selling_price' => $price,
'list_price' => $price,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => 100,
'is_default' => true,
]);
return [$product, $option];
}
/**
* 테스트 59: 과세 상품만 있는 경우
*
* 모든 상품 tax_status: taxable
* 기대: taxFreeAmount = 0
*/
public function test_it_classifies_taxable_products_only(): void
{
// Given: 과세 상품 2개
[$product1, $option1] = $this->createProductWithTaxStatus(30000, ProductTaxStatus::TAXABLE);
[$product2, $option2] = $this->createProductWithTaxStatus(20000, ProductTaxStatus::TAXABLE);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 모두 과세
$this->assertEquals(50000, $result->summary->taxableAmount);
$this->assertEquals(0, $result->summary->taxFreeAmount);
// 개별 아이템 확인
$this->assertEquals(30000, $result->items[0]->taxableAmount);
$this->assertEquals(0, $result->items[0]->taxFreeAmount);
$this->assertEquals(20000, $result->items[1]->taxableAmount);
$this->assertEquals(0, $result->items[1]->taxFreeAmount);
}
/**
* 테스트 60: 면세 상품만 있는 경우
*
* 모든 상품 tax_status: tax_free
* 기대: taxableAmount = 0
*/
public function test_it_classifies_tax_free_products_only(): void
{
// Given: 면세 상품 2개
[$product1, $option1] = $this->createProductWithTaxStatus(30000, ProductTaxStatus::TAX_FREE);
[$product2, $option2] = $this->createProductWithTaxStatus(20000, ProductTaxStatus::TAX_FREE);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 모두 면세
$this->assertEquals(0, $result->summary->taxableAmount);
$this->assertEquals(50000, $result->summary->taxFreeAmount);
// 개별 아이템 확인
$this->assertEquals(0, $result->items[0]->taxableAmount);
$this->assertEquals(30000, $result->items[0]->taxFreeAmount);
$this->assertEquals(0, $result->items[1]->taxableAmount);
$this->assertEquals(20000, $result->items[1]->taxFreeAmount);
}
/**
* 테스트 61: 과세/면세 혼합 상품
*
* 과세 30,000원, 면세 20,000원
* 기대: taxable: 30,000, taxFree: 20,000
*/
public function test_it_classifies_mixed_tax_status_products(): void
{
// Given: 과세 1개, 면세 1개
[$product1, $option1] = $this->createProductWithTaxStatus(30000, ProductTaxStatus::TAXABLE);
[$product2, $option2] = $this->createProductWithTaxStatus(20000, ProductTaxStatus::TAX_FREE);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product1->id,
productOptionId: $option1->id,
quantity: 1
),
new CalculationItem(
productId: $product2->id,
productOptionId: $option2->id,
quantity: 1
),
]
);
// When
$result = $this->service->calculate($input);
// Then: 과세 30,000 + 면세 20,000
$this->assertEquals(30000, $result->summary->taxableAmount);
$this->assertEquals(20000, $result->summary->taxFreeAmount);
// 개별 아이템 확인
$this->assertEquals(30000, $result->items[0]->taxableAmount);
$this->assertEquals(0, $result->items[0]->taxFreeAmount);
$this->assertEquals(0, $result->items[1]->taxableAmount);
$this->assertEquals(20000, $result->items[1]->taxFreeAmount);
}
// ========================================
// Section 7.10: 쿠폰 검증 규칙 (6개)
// ========================================
/**
* 테스트 62: 중복할인불가 쿠폰 단독 사용
*
* is_combinable: false 단독 사용 → 정상 적용
*/
public function test_it_allows_non_combinable_coupon_alone(): void
{
// Given
[$product, $option] = $this->createProductWithOption(50000);
$user = User::factory()->create();
$coupon = Coupon::create([
'code' => 'NONCOMBO'.uniqid(),
'name' => ['ko' => '중복불가 쿠폰', 'en' => 'Non-combinable Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 5000,
'min_order_amount' => 0,
'is_combinable' => false,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 정상 적용, 검증 오류 없음
$this->assertEmpty($result->validationErrors);
$this->assertEquals(5000, $result->summary->productCouponDiscount);
}
/**
* 테스트 63: 중복할인불가 + 다른 쿠폰 → 검증 오류
*
* is_combinable: false + 다른 쿠폰 → validationErrors 포함
*/
public function test_it_rejects_non_combinable_coupon_with_other_coupons(): void
{
// Given
[$product, $option] = $this->createProductWithOption(50000);
$user = User::factory()->create();
// 중복불가 쿠폰
$nonComboCoupon = Coupon::create([
'code' => 'NONCOMBO'.uniqid(),
'name' => ['ko' => '중복불가 쿠폰', 'en' => 'Non-combinable Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 3000,
'min_order_amount' => 0,
'is_combinable' => false,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
// 일반 쿠폰
$normalCoupon = Coupon::create([
'code' => 'NORMAL'.uniqid(),
'name' => ['ko' => '일반 쿠폰', 'en' => 'Normal Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 2000,
'min_order_amount' => 0,
'is_combinable' => true,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
$issue1 = CouponIssue::create([
'coupon_id' => $nonComboCoupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
$issue2 = CouponIssue::create([
'coupon_id' => $normalCoupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$issue1->id, $issue2->id]
);
// When
$result = $this->service->calculate($input);
// Then: 검증 오류 포함
$this->assertNotEmpty($result->validationErrors);
}
/**
* 테스트 63b (A15/MP06): 슬롯 교차 중복불가 검증
*
* is_combinable=false 쿠폰(상품별 itemCoupons 슬롯) + is_combinable=true 쿠폰(주문 슬롯)을
* 서로 다른 슬롯에 적용하면 슬롯별 count=1 이라 기존 코드는 미검증(우회) → 2개 동시 적용.
* 전역 1회 검증으로 false 쿠폰을 not_combinable 차단해야 한다.
*/
public function test_it_rejects_non_combinable_coupon_across_slots(): void
{
// Given
[$product, $option] = $this->createProductWithOption(50000);
$user = User::factory()->create();
// 상품별 슬롯에 들어갈 중복불가 쿠폰(itemCoupons)
$nonComboCoupon = Coupon::create([
'code' => 'NONCOMBO'.uniqid(),
'name' => ['ko' => '중복불가 쿠폰', 'en' => 'Non-combinable Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 3000,
'min_order_amount' => 0,
'is_combinable' => false,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
// 주문 슬롯에 들어갈 일반 쿠폰(couponIssueIds)
$orderCoupon = Coupon::create([
'code' => 'ORDER'.uniqid(),
'name' => ['ko' => '주문 쿠폰', 'en' => 'Order Coupon'],
'target_type' => CouponTargetType::ORDER_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 2000,
'min_order_amount' => 0,
'is_combinable' => true,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
$itemIssue = CouponIssue::create([
'coupon_id' => $nonComboCoupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
$orderIssue = CouponIssue::create([
'coupon_id' => $orderCoupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
// itemCoupons(상품별 슬롯) + couponIssueIds(주문 슬롯) 교차 적용
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$orderIssue->id],
itemCoupons: [$option->id => [$itemIssue->id]],
);
// When
$result = $this->service->calculate($input);
// Then: 슬롯 교차여도 not_combinable 차단 (false 쿠폰 기준)
$this->assertNotEmpty($result->validationErrors);
$codes = array_map(fn ($e) => $e->code, $result->validationErrors);
$this->assertContains('not_combinable', $codes);
// 그리고 위반(중복불가) 쿠폰은 할인에서 자동 제외되어야 한다 (소프트 표면화 — A15/MP06).
// 에러만 담고 할인은 그대로 적용하면 "위반인데 할인은 먹는" 모순 상태가 된다.
$this->assertEquals(0, $result->summary->productCouponDiscount, '중복불가 위반 상품쿠폰은 할인 제외되어야 한다');
}
/**
* 테스트 64: 최소주문금액 충족 → 정상 적용
*
* min_order_amount: 30,000, 주문: 50,000 → 정상 적용
*/
public function test_it_applies_coupon_when_min_order_amount_met(): void
{
// Given
[$product, $option] = $this->createProductWithOption(50000);
$user = User::factory()->create();
$coupon = Coupon::create([
'code' => 'MIN30K'.uniqid(),
'name' => ['ko' => '최소 3만원 쿠폰', 'en' => 'Min 30K Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 5000,
'min_order_amount' => 30000,
'is_combinable' => true,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 정상 적용
$this->assertEmpty($result->validationErrors);
$this->assertEquals(5000, $result->summary->productCouponDiscount);
}
/**
* 테스트 65: 최소주문금액 미달 → 검증 오류
*
* min_order_amount: 50,000, 주문: 30,000 → validationErrors 포함
*/
public function test_it_rejects_coupon_when_min_order_amount_not_met(): void
{
// Given
[$product, $option] = $this->createProductWithOption(30000);
$user = User::factory()->create();
$coupon = Coupon::create([
'code' => 'MIN50K'.uniqid(),
'name' => ['ko' => '최소 5만원 쿠폰', 'en' => 'Min 50K Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 5000,
'min_order_amount' => 50000,
'is_combinable' => true,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 검증 오류 포함
$this->assertNotEmpty($result->validationErrors);
// 쿠폰이 적용되지 않음
$this->assertEquals(0, $result->summary->productCouponDiscount);
}
/**
* 테스트 66: 특정상품 쿠폰 - 대상 상품 없음 → 검증 오류
*
* products: [999], 장바구니에 없는 상품 → validationErrors 포함
*/
public function test_it_rejects_product_specific_coupon_without_target_product(): void
{
// Given: 상품 생성
[$product, $option] = $this->createProductWithOption(50000);
$user = User::factory()->create();
// 다른 상품을 대상으로 하는 쿠폰 (장바구니에 없는 상품 ID)
$coupon = Coupon::create([
'code' => 'SPECIFIC'.uniqid(),
'name' => ['ko' => '특정상품 쿠폰', 'en' => 'Specific Product Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::PRODUCTS,
'target_ids' => [999999], // 존재하지 않는 상품 ID
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 5000,
'min_order_amount' => 0,
'is_combinable' => true,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 검증 오류 포함 또는 할인 미적용
// 대상 상품이 없으면 할인이 0이거나 validationErrors가 있어야 함
$this->assertTrue(
! empty($result->validationErrors) || $result->summary->productCouponDiscount === 0
);
}
/**
* 테스트 67: 쿠폰 유효기간 만료 → 검증 오류
*
* expires_at < now() → validationErrors 포함
*/
public function test_it_rejects_expired_coupon(): void
{
// Given
[$product, $option] = $this->createProductWithOption(50000);
$user = User::factory()->create();
$coupon = Coupon::create([
'code' => 'EXPIRED'.uniqid(),
'name' => ['ko' => '만료 쿠폰', 'en' => 'Expired Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 5000,
'min_order_amount' => 0,
'is_combinable' => true,
'started_at' => now()->subWeek(),
'ended_at' => now()->subDay(), // 어제 만료
'is_active' => true,
]);
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now()->subWeek(),
'expired_at' => now()->subDay(), // 어제 만료
]);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 검증 오류 포함 또는 할인 미적용
$this->assertTrue(
! empty($result->validationErrors) || $result->summary->productCouponDiscount === 0
);
}
// ====================================================================
// Section 7.11: 안분 계산 정확성 테스트
// ====================================================================
/**
* 테스트 68: 할인 안분 후 합계 검증
*
* 주문쿠폰 할인 10,000원을 3개 옵션에 안분 → 합계 = 10,000원
*/
public function test_it_apportions_discount_with_exact_sum(): void
{
// Given: 3개 상품 (10000, 20000, 30000)
[$product1, $option1] = $this->createProductWithOption(10000);
[$product2, $option2] = $this->createProductWithOption(20000);
[$product3, $option3] = $this->createProductWithOption(30000);
// 주문쿠폰 10,000원 고정 할인
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::FIXED,
10000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
new CalculationItem(productId: $product3->id, productOptionId: $option3->id, quantity: 1),
],
couponIssueIds: [$orderCouponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 각 아이템의 orderDiscountShare 합계 = 10,000원
$totalOrderDiscountShare = 0;
foreach ($result->items as $item) {
$totalOrderDiscountShare += $item->orderCouponDiscountShare;
}
$this->assertEquals(10000, $totalOrderDiscountShare);
// 각 아이템의 안분액은 비율대로 분배됨
// 총액: 60000, 비율 = 10000:20000:30000 = 1:2:3
// 이론적 안분: 1667 + 3333 + 5000 = 10000
}
/**
* 테스트 69: 안분 시 나머지 마지막 항목 합산
*
* 나누어떨어지지 않는 경우 마지막 항목에 나머지 합산
*/
public function test_it_assigns_apportionment_remainder_to_last_item(): void
{
// Given: 3개 동일 상품 (각 10000원, 총 30000원)
[$product1, $option1] = $this->createProductWithOption(10000);
[$product2, $option2] = $this->createProductWithOption(10000);
[$product3, $option3] = $this->createProductWithOption(10000);
// 10원 할인 → 3등분 불가 (나머지 1원)
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::FIXED,
10,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
new CalculationItem(productId: $product3->id, productOptionId: $option3->id, quantity: 1),
],
couponIssueIds: [$orderCouponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 합계는 정확히 10원
$totalOrderDiscountShare = 0;
foreach ($result->items as $item) {
$totalOrderDiscountShare += $item->orderCouponDiscountShare;
}
$this->assertEquals(10, $totalOrderDiscountShare);
// 마지막 아이템 확인 - items 배열의 마지막 값
$itemsArray = array_values((array) $result->items);
$lastItem = end($itemsArray);
// 마지막 항목에 나머지가 포함되어 있음 (앞의 두 항목 합과 전체 합의 차이)
$firstTwoShare = $itemsArray[0]->orderCouponDiscountShare + $itemsArray[1]->orderCouponDiscountShare;
$this->assertEquals(10 - $firstTwoShare, $lastItem->orderCouponDiscountShare);
}
/**
* 테스트 70: 마일리지 안분 로직 검증
*
* 마일리지 사용금액이 각 아이템에 비율대로 안분됨
*/
public function test_it_apportions_mileage_by_subtotal_ratio(): void
{
// Given: 2개 상품 (10000원, 30000원 = 1:3 비율)
[$product1, $option1] = $this->createProductWithOption(10000);
[$product2, $option2] = $this->createProductWithOption(30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
],
usePoints: 4000 // 4000원 마일리지 사용
);
// When
$result = $this->service->calculate($input);
// Then: 마일리지 사용액 합계 = 4000원
$totalPointsUsedShare = 0;
foreach ($result->items as $item) {
$totalPointsUsedShare += $item->pointsUsedShare;
}
$this->assertEquals(4000, $totalPointsUsedShare);
// 안분 비율 확인 (1:3)
// 이론적: 1000 + 3000 = 4000
$itemsArray = array_values((array) $result->items);
// 첫 번째 아이템 (10000원) = 약 1000원
// 두 번째 아이템 (30000원) = 약 3000원
$this->assertEqualsWithDelta(1000, $itemsArray[0]->pointsUsedShare, 1);
$this->assertEqualsWithDelta(3000, $itemsArray[1]->pointsUsedShare, 1);
// summary의 pointsUsed도 확인
$this->assertEquals(4000, $result->summary->pointsUsed);
}
// ====================================================================
// Section 7.12: 복합 시나리오 테스트
// ====================================================================
/**
* 테스트 71: 2개 상품 + 상품쿠폰 + 고정배송비
*/
public function test_complex_two_products_with_product_coupon_and_fixed_shipping(): void
{
// Given: 배송정책 (고정 3000원)
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
// 2개 상품 (배송정책 포함)
[$product1, $option1] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
[$product2, $option2] = $this->createProductWithShippingPolicy(20000, $shippingPolicy);
// 상품쿠폰: 5000원 고정 할인
$couponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::FIXED,
5000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(50000, $result->summary->subtotal); // 30000 + 20000
// 상품쿠폰: Fixed 5000원 × 2개 상품 = 10000원
$this->assertEquals(10000, $result->summary->productCouponDiscount);
$this->assertEquals(3000, $result->summary->totalShipping);
// finalAmount = 50000 - 10000 + 3000 = 43000
$this->assertEquals(43000, $result->summary->finalAmount);
}
/**
* 테스트 72: 3개 상품 + 주문쿠폰 + 배송쿠폰
*/
public function test_complex_three_products_with_order_and_shipping_coupon(): void
{
// Given: 배송정책 (고정 5000원)
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 5000);
// 3개 상품 (배송정책 포함)
[$product1, $option1] = $this->createProductWithShippingPolicy(20000, $shippingPolicy);
[$product2, $option2] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
[$product3, $option3] = $this->createProductWithShippingPolicy(50000, $shippingPolicy);
// 주문쿠폰: 10% 할인
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::RATE,
10,
null
);
// 배송비쿠폰: 전액 무료
$shippingCouponIssue = $this->createShippingCouponWithIssue(
CouponDiscountType::FIXED,
5000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
new CalculationItem(productId: $product3->id, productOptionId: $option3->id, quantity: 1),
],
couponIssueIds: [$orderCouponIssue->id, $shippingCouponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(100000, $result->summary->subtotal); // 20000 + 30000 + 50000
$this->assertEquals(10000, $result->summary->orderCouponDiscount); // 100000 * 10%
$this->assertEquals(5000, $result->summary->totalShipping);
$this->assertEquals(5000, $result->summary->shippingDiscount);
$this->assertEquals(90000, $result->summary->finalAmount); // 100000 - 10000 + 5000 - 5000
}
/**
* 테스트 73: 상품쿠폰 + 주문쿠폰 + 마일리지 조합
*/
public function test_complex_product_coupon_order_coupon_and_mileage(): void
{
// Given: 2개 상품
[$product1, $option1] = $this->createProductWithOption(40000);
[$product2, $option2] = $this->createProductWithOption(60000);
// 상품쿠폰: 3000원
$productCouponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::FIXED,
3000,
null
);
// 주문쿠폰: 5%
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::RATE,
5,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
],
couponIssueIds: [$productCouponIssue->id, $orderCouponIssue->id],
usePoints: 5000
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(100000, $result->summary->subtotal);
// 상품쿠폰: Fixed 3000원 × 2개 상품 = 6000원
$this->assertEquals(6000, $result->summary->productCouponDiscount);
// 주문쿠폰: (100000 - 6000) * 5% = 4700
$this->assertEquals(4700, $result->summary->orderCouponDiscount);
$this->assertEquals(5000, $result->summary->pointsUsed);
// finalAmount = 100000 - 6000 - 4700 - 5000 = 84300
$this->assertEquals(84300, $result->summary->finalAmount);
}
/**
* 테스트 74: 과세/면세 혼합 + 할인 적용
*/
public function test_complex_mixed_tax_status_with_discount(): void
{
// Given: 과세 상품 + 면세 상품
[$productTaxable, $optionTaxable] = $this->createProductWithTaxStatus(30000, ProductTaxStatus::TAXABLE);
[$productTaxFree, $optionTaxFree] = $this->createProductWithTaxStatus(20000, ProductTaxStatus::TAX_FREE);
// 주문쿠폰: 5000원
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::FIXED,
5000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productTaxable->id, productOptionId: $optionTaxable->id, quantity: 1),
new CalculationItem(productId: $productTaxFree->id, productOptionId: $optionTaxFree->id, quantity: 1),
],
couponIssueIds: [$orderCouponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(50000, $result->summary->subtotal);
$this->assertEquals(5000, $result->summary->orderCouponDiscount);
// 과세/면세 금액 합계
$totalTaxable = 0;
$totalTaxFree = 0;
foreach ($result->items as $item) {
$totalTaxable += $item->taxableAmount;
$totalTaxFree += $item->taxFreeAmount;
}
$this->assertGreaterThan(0, $totalTaxable);
$this->assertGreaterThan(0, $totalTaxFree);
}
/**
* 테스트 75: 다중 배송정책 + 상품쿠폰 + 주문쿠폰
*/
public function test_complex_multiple_shipping_policies_with_coupons(): void
{
// Given: 3개 상품, 각각 다른 배송정책
[$product1, $option1] = $this->createProductWithOption(20000);
[$product2, $option2] = $this->createProductWithOption(30000);
[$product3, $option3] = $this->createProductWithOption(50000);
$freePolicy = $this->createShippingPolicy(ChargePolicyEnum::FREE);
$fixedPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
$conditionalPolicy = $this->createShippingPolicy(ChargePolicyEnum::CONDITIONAL_FREE, 5000, 40000);
$product1->update(['shipping_policy_id' => $freePolicy->id]);
$product2->update(['shipping_policy_id' => $fixedPolicy->id]);
$product3->update(['shipping_policy_id' => $conditionalPolicy->id]); // 50000원 > 40000원 → 무료
// 상품쿠폰: 10%
$productCouponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::RATE,
10,
null
);
// 주문쿠폰: 2000원
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::FIXED,
2000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
new CalculationItem(productId: $product3->id, productOptionId: $option3->id, quantity: 1),
],
couponIssueIds: [$productCouponIssue->id, $orderCouponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(100000, $result->summary->subtotal);
$this->assertEquals(10000, $result->summary->productCouponDiscount); // 100000 * 10%
// 주문쿠폰: 2000원
$this->assertEquals(2000, $result->summary->orderCouponDiscount);
// 배송비: 무료 + 3000 + 무료(조건충족) = 3000
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 테스트 76: 수량 2개 이상 + 배송비 + 쿠폰
*/
public function test_complex_multiple_quantity_with_shipping_and_coupon(): void
{
// Given: 1개 상품, 수량 3개
[$product, $option] = $this->createProductWithOption(10000);
// 수량별 배송비 정책
$perQuantityPolicy = $this->createShippingPolicy(ChargePolicyEnum::PER_QUANTITY, 1000);
$product->update(['shipping_policy_id' => $perQuantityPolicy->id]);
// 상품쿠폰: 5%
$couponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::RATE,
5,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(30000, $result->summary->subtotal); // 10000 * 3
$this->assertEquals(1500, $result->summary->productCouponDiscount); // 30000 * 5%
$this->assertEquals(3000, $result->summary->totalShipping); // 1000 * 3
$this->assertEquals(31500, $result->summary->finalAmount); // 30000 - 1500 + 3000
}
/**
* 테스트 77: 전체 할인 시 0원 결제
*/
public function test_complex_full_discount_zero_payment(): void
{
// Given: 소액 상품
[$product, $option] = $this->createProductWithOption(5000);
// 배송비 무료
$freePolicy = $this->createShippingPolicy(ChargePolicyEnum::FREE);
$product->update(['shipping_policy_id' => $freePolicy->id]);
// 상품쿠폰: 5000원 (전액)
$couponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::FIXED,
5000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(5000, $result->summary->subtotal);
$this->assertEquals(5000, $result->summary->productCouponDiscount);
$this->assertEquals(0, $result->summary->finalAmount);
}
/**
* 테스트 78: 할인 + 마일리지로 0원 결제
*/
public function test_complex_discount_and_mileage_zero_payment(): void
{
// Given
[$product, $option] = $this->createProductWithOption(10000);
// 상품쿠폰: 5000원
$couponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::FIXED,
5000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 5000 // 나머지 5000원을 마일리지로
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(10000, $result->summary->subtotal);
$this->assertEquals(5000, $result->summary->productCouponDiscount);
$this->assertEquals(5000, $result->summary->pointsUsed);
$this->assertEquals(0, $result->summary->finalAmount);
}
/**
* 테스트 79: 복수 상품 동일 배송정책 그룹화
*/
public function test_complex_same_shipping_policy_grouped(): void
{
// Given: 3개 상품, 같은 배송정책 → 그룹화
[$product1, $option1] = $this->createProductWithOption(10000);
[$product2, $option2] = $this->createProductWithOption(20000);
[$product3, $option3] = $this->createProductWithOption(30000);
// 조건부 무료: 50000원 이상 무료, 미만 시 3000원
$conditionalPolicy = $this->createShippingPolicy(ChargePolicyEnum::CONDITIONAL_FREE, 3000, 50000);
$product1->update(['shipping_policy_id' => $conditionalPolicy->id]);
$product2->update(['shipping_policy_id' => $conditionalPolicy->id]);
$product3->update(['shipping_policy_id' => $conditionalPolicy->id]);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
new CalculationItem(productId: $product3->id, productOptionId: $option3->id, quantity: 1),
]
);
// When
$result = $this->service->calculate($input);
// Then: 총 60000원 > 50000원 → 배송비 무료
$this->assertEquals(60000, $result->summary->subtotal);
$this->assertEquals(0, $result->summary->totalShipping);
$this->assertEquals(60000, $result->summary->finalAmount);
}
/**
* 테스트 80: 특정 상품 쿠폰 + 주문쿠폰 조합
*/
public function test_complex_specific_product_coupon_with_order_coupon(): void
{
// Given: 2개 상품
[$product1, $option1] = $this->createProductWithOption(30000);
[$product2, $option2] = $this->createProductWithOption(20000);
// product1 전용 쿠폰: 3000원
$productCouponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::PRODUCTS,
CouponDiscountType::FIXED,
3000,
null,
[$product1->id]
);
// 주문쿠폰: 10%
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::RATE,
10,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
],
couponIssueIds: [$productCouponIssue->id, $orderCouponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(50000, $result->summary->subtotal);
$this->assertEquals(3000, $result->summary->productCouponDiscount); // product1에만 적용
// 주문쿠폰: (50000 - 3000) * 10% = 4700
$this->assertEquals(4700, $result->summary->orderCouponDiscount);
// finalAmount = 50000 - 3000 - 4700 = 42300
$this->assertEquals(42300, $result->summary->finalAmount);
}
/**
* 테스트 81: 정률쿠폰 최대할인 제한 + 주문쿠폰
*/
public function test_complex_rate_coupon_max_discount_with_order_coupon(): void
{
// Given
[$product, $option] = $this->createProductWithOption(100000);
// 상품쿠폰: 50% (최대 10000원)
$productCouponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::RATE,
50,
10000
);
// 주문쿠폰: 5000원
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::FIXED,
5000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$productCouponIssue->id, $orderCouponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(100000, $result->summary->subtotal);
// 50% = 50000원이지만 최대 10000원
$this->assertEquals(10000, $result->summary->productCouponDiscount);
$this->assertEquals(5000, $result->summary->orderCouponDiscount);
// finalAmount = 100000 - 10000 - 5000 = 85000
$this->assertEquals(85000, $result->summary->finalAmount);
}
/**
* 테스트 82: 마일리지 전액 사용 (결제금액 초과 요청)
*/
public function test_complex_mileage_exceeds_payment_amount(): void
{
// Given
[$product, $option] = $this->createProductWithOption(10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
usePoints: 20000 // 결제금액(10000원)보다 많이 요청
);
// When
$result = $this->service->calculate($input);
// Then: 결제금액만큼만 사용됨
$this->assertEquals(10000, $result->summary->subtotal);
$this->assertEquals(10000, $result->summary->pointsUsed); // 실제 사용은 10000원
$this->assertEquals(0, $result->summary->finalAmount);
}
/**
* 테스트 83: 구간별 배송비 + 수량 조합
*/
public function test_complex_range_shipping_with_quantity(): void
{
// Given
[$product, $option] = $this->createProductWithOption(5000);
// 수량별 구간 배송비: 1-2개: 3000원, 3-5개: 5000원, 6개+: 7000원
$rangePolicy = $this->createShippingPolicy(
ChargePolicyEnum::RANGE_QUANTITY,
0,
null,
[
'tiers' => [
['min' => 0, 'max' => 2, 'fee' => 3000],
['min' => 2, 'max' => 5, 'fee' => 5000],
['min' => 5, 'max' => null, 'fee' => 7000],
],
]
);
$product->update(['shipping_policy_id' => $rangePolicy->id]);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 4),
]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(20000, $result->summary->subtotal); // 5000 * 4
$this->assertEquals(5000, $result->summary->totalShipping); // 4개 → 3-5개 구간
$this->assertEquals(25000, $result->summary->finalAmount);
}
/**
* 테스트 84: 금액별 구간 배송비 + 쿠폰
*/
public function test_complex_range_amount_shipping_with_coupon(): void
{
// Given
[$product, $option] = $this->createProductWithOption(35000);
// 금액별 구간 배송비
$rangePolicy = $this->createShippingPolicy(
ChargePolicyEnum::RANGE_AMOUNT,
0,
null,
[
'tiers' => [
['min' => 0, 'max' => 30000, 'fee' => 5000],
['min' => 30000, 'max' => 50000, 'fee' => 3000],
['min' => 50000, 'max' => null, 'fee' => 0],
],
]
);
$product->update(['shipping_policy_id' => $rangePolicy->id]);
// 상품쿠폰: 5000원
$couponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::FIXED,
5000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(35000, $result->summary->subtotal);
$this->assertEquals(5000, $result->summary->productCouponDiscount);
// 배송비는 쿠폰 할인 후 금액 기준: 35,000 - 5,000 = 30,000원 → "~30,000원" 구간(상한 포함) → 5,000원
$this->assertEquals(5000, $result->summary->totalShipping);
// finalAmount = 35000 - 5000 + 5000 = 35000
$this->assertEquals(35000, $result->summary->finalAmount);
}
/**
* 테스트 85: 전체 시나리오 - 상품쿠폰 + 주문쿠폰 + 배송쿠폰 + 마일리지
*/
public function test_complex_full_scenario_all_discounts(): void
{
// Given: 2개 상품
[$product1, $option1] = $this->createProductWithOption(50000);
[$product2, $option2] = $this->createProductWithOption(50000);
// 배송정책 (고정 5000원)
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 5000);
$product1->update(['shipping_policy_id' => $shippingPolicy->id]);
$product2->update(['shipping_policy_id' => $shippingPolicy->id]);
// 상품쿠폰: 10000원
$productCouponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::FIXED,
10000,
null
);
// 주문쿠폰: 5%
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::RATE,
5,
null
);
// 배송비쿠폰: 50% (2500원)
$shippingCouponIssue = $this->createShippingCouponWithIssue(
CouponDiscountType::RATE,
50,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
],
couponIssueIds: [$productCouponIssue->id, $orderCouponIssue->id, $shippingCouponIssue->id],
usePoints: 10000
);
// When
$result = $this->service->calculate($input);
// Then
$this->assertEquals(100000, $result->summary->subtotal);
// 상품쿠폰: Fixed 10000원 × 2개 상품 = 20000원
$this->assertEquals(20000, $result->summary->productCouponDiscount);
// 주문쿠폰: (100000 - 20000) * 5% = 4000
$this->assertEquals(4000, $result->summary->orderCouponDiscount);
$this->assertEquals(5000, $result->summary->totalShipping);
$this->assertEquals(2500, $result->summary->shippingDiscount); // 5000 * 50%
$this->assertEquals(10000, $result->summary->pointsUsed);
// finalAmount = 100000 - 20000 - 4000 + 5000 - 2500 - 10000 = 68500
$this->assertEquals(68500, $result->summary->finalAmount);
}
/**
* 테스트 86: 적립금 계산 검증 (기본 1%)
*/
public function test_complex_points_earning_default_rate(): void
{
// Given
[$product, $option] = $this->createProductWithOption(100000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
);
// When
$result = $this->service->calculate($input);
// Then: 기본 적립율 1%
$this->assertEquals(1000, $result->summary->pointsEarning); // 100000 * 1%
}
/**
* 테스트 87: 다중 옵션 배송정책 별도 적용
*/
public function test_complex_multiple_options_separate_shipping_policies(): void
{
// Given: 3개 상품, 2개 다른 배송정책
[$product1, $option1] = $this->createProductWithOption(20000);
[$product2, $option2] = $this->createProductWithOption(30000);
[$product3, $option3] = $this->createProductWithOption(50000);
$policyA = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
$policyB = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 5000);
$product1->update(['shipping_policy_id' => $policyA->id]);
$product2->update(['shipping_policy_id' => $policyA->id]); // 같은 정책
$product3->update(['shipping_policy_id' => $policyB->id]); // 다른 정책
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
new CalculationItem(productId: $product3->id, productOptionId: $option3->id, quantity: 1),
]
);
// When
$result = $this->service->calculate($input);
// Then: policyA(3000) + policyB(5000) = 8000
$this->assertEquals(100000, $result->summary->subtotal);
$this->assertEquals(8000, $result->summary->totalShipping);
$this->assertEquals(108000, $result->summary->finalAmount);
}
// ========================================
// Section 7.12.3: 배송비 복합 시나리오 추가
// ========================================
/**
* 테스트 84: 배송비가 상품가보다 높은 경우
*
* 시나리오: 상품 5,000원, 배송비 7,000원
* 검증: 정상 계산 (음수 아님)
*/
public function test_complex_shipping_higher_than_product_price(): void
{
// Given: 상품 5,000원, 배송비 7,000원
[$product, $option] = $this->createProductWithOption(5000);
$policy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 7000);
$product->update(['shipping_policy_id' => $policy->id]);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
);
// When
$result = $this->service->calculate($input);
// Then: 정상 계산
$this->assertEquals(5000, $result->summary->subtotal);
$this->assertEquals(7000, $result->summary->totalShipping);
$this->assertEquals(12000, $result->summary->finalAmount);
// 음수가 아님을 확인
$this->assertGreaterThan(0, $result->summary->finalAmount);
}
// ========================================
// Section 7.12.4: 금액 경계값 시나리오 추가
// ========================================
/**
* 테스트 94: 마일리지 전액 사용
*
* 시나리오: 결제금액 50,000원, 마일리지 50,000원 사용
* 검증: finalAmount = 0
*/
public function test_complex_mileage_full_payment(): void
{
// Given
[$product, $option] = $this->createProductWithOption(50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
usePoints: 50000
);
// When
$result = $this->service->calculate($input);
// Then: 마일리지로 전액 결제
$this->assertEquals(50000, $result->summary->subtotal);
$this->assertEquals(50000, $result->summary->pointsUsed);
$this->assertEquals(0, $result->summary->finalAmount);
}
// ========================================
// Section 7.12.6: 특수 케이스 시나리오 추가
// ========================================
/**
* 테스트 100: 전체 옵션 취소 (빈 장바구니)
*
* 시나리오: 모든 옵션 취소 후 재계산
* 검증: 빈 결과, 0원 처리
*/
public function test_complex_empty_cart_calculation(): void
{
// Given: 빈 장바구니
$input = new CalculationInput(
items: []
);
// When
$result = $this->service->calculate($input);
// Then: 모든 값이 0
$this->assertEmpty($result->items);
$this->assertEquals(0, $result->summary->subtotal);
$this->assertEquals(0, $result->summary->totalShipping);
$this->assertEquals(0, $result->summary->productCouponDiscount);
$this->assertEquals(0, $result->summary->orderCouponDiscount);
$this->assertEquals(0, $result->summary->pointsUsed);
$this->assertEquals(0, $result->summary->finalAmount);
}
// ========================================
// Section 7.12.5: 옵션/수량 변동 테스트 (3개)
// ========================================
/**
* 테스트 101: 수량 증가 시 수량당 배송비 재계산
*
* 시나리오: 수량 2개 → 5개로 변경 시 배송비 변화
* 검증: 배송비가 수량에 비례하여 증가
*/
public function test_quantity_change_affects_per_quantity_shipping(): void
{
// Given: 수량당 배송비 정책 (1,000원/개)
$policy = $this->createShippingPolicy(ChargePolicyEnum::PER_QUANTITY, 1000);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $policy);
// When: 수량 2개
$input2 = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
]
);
$result2 = $this->service->calculate($input2);
// When: 수량 5개
$input5 = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 5),
]
);
$result5 = $this->service->calculate($input5);
// Then: 수량 2개 → 배송비 2,000원
$this->assertEquals(20000, $result2->summary->subtotal);
$this->assertEquals(2000, $result2->summary->totalShipping);
$this->assertEquals(22000, $result2->summary->finalAmount);
// Then: 수량 5개 → 배송비 5,000원
$this->assertEquals(50000, $result5->summary->subtotal);
$this->assertEquals(5000, $result5->summary->totalShipping);
$this->assertEquals(55000, $result5->summary->finalAmount);
}
/**
* 테스트 102: 수량 변경으로 조건부 무료배송 달성/미달
*
* 시나리오: 30,000원 이상 무료배송, 상품 10,000원
* - 수량 2개: 20,000원 < 30,000원 → 배송비 3,000원
* - 수량 3개: 30,000원 >= 30,000원 → 배송비 무료
*/
public function test_quantity_change_conditional_free_shipping_threshold(): void
{
// Given: 조건부 무료배송 정책 (30,000원 이상 무료, 기본 3,000원)
$policy = $this->createShippingPolicy(ChargePolicyEnum::CONDITIONAL_FREE, 3000, 30000);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $policy);
// When: 수량 2개 (20,000원 - 무료배송 미달)
$input2 = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
]
);
$result2 = $this->service->calculate($input2);
// When: 수량 3개 (30,000원 - 무료배송 달성)
$input3 = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3),
]
);
$result3 = $this->service->calculate($input3);
// Then: 수량 2개 → 배송비 3,000원 (미달)
$this->assertEquals(20000, $result2->summary->subtotal);
$this->assertEquals(3000, $result2->summary->totalShipping);
$this->assertEquals(23000, $result2->summary->finalAmount);
// Then: 수량 3개 → 배송비 무료 (달성)
$this->assertEquals(30000, $result3->summary->subtotal);
$this->assertEquals(0, $result3->summary->totalShipping);
$this->assertEquals(30000, $result3->summary->finalAmount);
}
/**
* 테스트 103: 다른 배송정책 상품 추가 시 그룹별 배송비 계산
*
* 시나리오:
* - 상품A(무료배송)만: 배송비 0원
* - 상품A + 상품B(고정 3,000원) 추가: 배송비 3,000원
*/
public function test_adding_product_with_different_shipping_policy(): void
{
// Given: 무료배송 정책 상품
$freePollicy = $this->createShippingPolicy(ChargePolicyEnum::FREE);
[$productA, $optionA] = $this->createProductWithShippingPolicy(20000, $freePollicy);
// Given: 고정배송비 정책 상품
$fixedPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
[$productB, $optionB] = $this->createProductWithShippingPolicy(15000, $fixedPolicy);
// When: 상품A만
$inputA = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
]
);
$resultA = $this->service->calculate($inputA);
// When: 상품A + 상품B
$inputAB = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
]
);
$resultAB = $this->service->calculate($inputAB);
// Then: 상품A만 → 배송비 0원
$this->assertEquals(20000, $resultA->summary->subtotal);
$this->assertEquals(0, $resultA->summary->totalShipping);
$this->assertEquals(20000, $resultA->summary->finalAmount);
// Then: 상품A + 상품B → 배송비 3,000원 (무료 + 고정)
$this->assertEquals(35000, $resultAB->summary->subtotal);
$this->assertEquals(3000, $resultAB->summary->totalShipping);
$this->assertEquals(38000, $resultAB->summary->finalAmount);
}
// ========================================
// Section 7.12.3.1: 추가배송비(도서산간) 복합 시나리오 (6개)
// ========================================
/**
* 테스트 85: 전체 흐름 - 상품쿠폰 + 주문쿠폰 + 추가배송비 + 배송비쿠폰
*
* 3개 상품, 도서산간 배송지, 모든 쿠폰 적용
* 기본/추가 배송비 분리, 쿠폰 할인은 총액 기준
*/
public function test_complex_full_flow_with_extra_shipping_and_all_coupons(): void
{
// Given: 고정 배송비 정책 + 도서산간 추가배송비
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 5000],
]
);
// Given: 3개 상품
[$product1, $option1] = $this->createProductWithShippingPolicy(30000, $policy);
[$product2, $option2] = $this->createProductWithShippingPolicy(20000, $policy);
[$product3, $option3] = $this->createProductWithShippingPolicy(50000, $policy);
// Given: 상품 쿠폰 (상품1에 10% 할인)
$productCouponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::PRODUCTS,
CouponDiscountType::RATE,
10,
targetIds: [$product1->id]
);
// Given: 주문 쿠폰 (3,000원 할인)
$orderCouponIssue = $this->createOrderCouponWithIssue(CouponDiscountType::FIXED, 3000);
// Given: 배송비 쿠폰 (50% 할인)
$shippingCouponIssue = $this->createShippingCouponWithIssue(CouponDiscountType::RATE, 50);
// When: 도서산간 배송지, 모든 쿠폰 적용
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product1->id, productOptionId: $option1->id, quantity: 1),
new CalculationItem(productId: $product2->id, productOptionId: $option2->id, quantity: 1),
new CalculationItem(productId: $product3->id, productOptionId: $option3->id, quantity: 1),
],
couponIssueIds: [$productCouponIssue->id, $orderCouponIssue->id, $shippingCouponIssue->id],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '63123')
);
$result = $this->service->calculate($input);
// Then: 상품금액 100,000원 (30,000 + 20,000 + 50,000)
$this->assertEquals(100000, $result->summary->subtotal);
// Then: 상품쿠폰 할인 3,000원 (30,000 × 10%)
$this->assertEquals(3000, $result->summary->productCouponDiscount);
// Then: 주문쿠폰 할인 3,000원
$this->assertEquals(3000, $result->summary->orderCouponDiscount);
// Then: 총 할인 6,000원
$this->assertEquals(6000, $result->summary->totalDiscount);
// Then: 기본 배송비 3,000원, 추가배송비 5,000원, 총 배송비 8,000원
$this->assertEquals(3000, $result->summary->baseShippingTotal);
$this->assertEquals(5000, $result->summary->extraShippingTotal);
$this->assertEquals(8000, $result->summary->totalShipping);
// Then: 배송비 쿠폰 50% → 4,000원 할인
$this->assertEquals(4000, $result->summary->shippingDiscount);
// Then: 최종금액 = 100,000 - 6,000 + 8,000 - 4,000 = 98,000원
$this->assertEquals(98000, $result->summary->finalAmount);
}
/**
* 테스트 86: 다중 정책 + 각각 다른 추가배송비
*
* 정책A: 추가배송비 5,000원, 정책B: 추가배송비 3,000원, 정책C: 추가배송비 없음
* 각 정책별 추가배송비 독립 계산
*/
public function test_complex_multiple_policies_with_different_extra_fees(): void
{
// Given: 정책A - 추가배송비 5,000원
$policyA = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 2000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 5000],
]
);
// Given: 정책B - 추가배송비 3,000원
$policyB = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 2500,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
]
);
// Given: 정책C - 추가배송비 없음
$policyC = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: false
);
[$productA, $optionA] = $this->createProductWithShippingPolicy(20000, $policyA);
[$productB, $optionB] = $this->createProductWithShippingPolicy(30000, $policyB);
[$productC, $optionC] = $this->createProductWithShippingPolicy(40000, $policyC);
// When: 도서산간 배송지
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
new CalculationItem(productId: $productC->id, productOptionId: $optionC->id, quantity: 1),
],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '63500')
);
$result = $this->service->calculate($input);
// Then: 기본 배송비 = 2,000 + 2,500 + 3,000 = 7,500원
$this->assertEquals(7500, $result->summary->baseShippingTotal);
// Then: 추가 배송비 = 5,000 + 3,000 + 0 = 8,000원
$this->assertEquals(8000, $result->summary->extraShippingTotal);
// Then: 총 배송비 = 15,500원
$this->assertEquals(15500, $result->summary->totalShipping);
// Then: 최종금액 = 90,000 + 15,500 = 105,500원
$this->assertEquals(90000, $result->summary->subtotal);
$this->assertEquals(105500, $result->summary->finalAmount);
}
/**
* 테스트 87: per_* 정책 혼합 + multiply 설정 혼합
*
* PER_QUANTITY(multiply=true) + FIXED(multiply=false)
* 정책별 multiply 설정 독립 적용
*/
public function test_complex_per_policies_with_mixed_multiply_settings(): void
{
// Given: PER_QUANTITY 정책 + multiply=true
$policyA = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 2], // 2개당 1,000원
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 2000],
],
extraFeeMultiply: true
);
// Given: FIXED 정책 + multiply=false (기본)
$policyB = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
],
extraFeeMultiply: false
);
[$productA, $optionA] = $this->createProductWithShippingPolicy(10000, $policyA);
[$productB, $optionB] = $this->createProductWithShippingPolicy(15000, $policyB);
// When: 상품A 5개, 상품B 3개 → 도서산간 배송지
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 5),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 3),
],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '63123')
);
$result = $this->service->calculate($input);
// Then: 상품금액 = 50,000 + 45,000 = 95,000원
$this->assertEquals(95000, $result->summary->subtotal);
// Then: 기본 배송비
// 정책A: ceil(5/2) × 1,000 = 3,000원
// 정책B: 3,000원 (고정)
// 합계: 6,000원
$this->assertEquals(6000, $result->summary->baseShippingTotal);
// Then: 추가 배송비
// 정책A: multiply=true → 2,000 × 3 = 6,000원 (배송비 단위 수만큼)
// 정책B: multiply=false → 3,000원 (1회만)
// 합계: 9,000원
$this->assertEquals(9000, $result->summary->extraShippingTotal);
// Then: 총 배송비 = 15,000원
$this->assertEquals(15000, $result->summary->totalShipping);
}
/**
* 테스트 88: 무료배송 조건 충족 + 추가배송비
*
* 기본배송비 무료 조건 충족, 도서산간 지역
* baseShippingTotal=0, extraShippingTotal > 0
*/
public function test_complex_free_shipping_with_extra_fee(): void
{
// Given: CONDITIONAL_FREE 정책 (5만원 이상 무료) + 추가배송비
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 5000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(60000, $policy);
// When: 60,000원 상품 (무료배송 조건 충족) + 도서산간
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '63456')
);
$result = $this->service->calculate($input);
// Then: 기본 배송비 0원 (무료배송 조건 충족)
$this->assertEquals(0, $result->summary->baseShippingTotal);
// Then: 추가 배송비 5,000원 (도서산간)
$this->assertEquals(5000, $result->summary->extraShippingTotal);
// Then: 총 배송비 5,000원
$this->assertEquals(5000, $result->summary->totalShipping);
// Then: 최종금액 = 60,000 + 5,000 = 65,000원
$this->assertEquals(65000, $result->summary->finalAmount);
}
/**
* 테스트 89: 배송지 변경으로 추가배송비 발생
*
* 일반지역 → 도서산간 지역 변경
* 추가배송비 동적 재계산
*/
public function test_complex_address_change_adds_extra_fee(): void
{
// Given: 추가배송비 설정된 정책
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 5000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(50000, $policy);
// When: 일반 지역 배송
$inputNormal = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '12345')
);
$resultNormal = $this->service->calculate($inputNormal);
// When: 도서산간 지역으로 변경
$inputIsland = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '63789')
);
$resultIsland = $this->service->calculate($inputIsland);
// Then: 일반 지역 - 추가배송비 0원
$this->assertEquals(0, $resultNormal->summary->extraShippingTotal);
$this->assertEquals(3000, $resultNormal->summary->totalShipping);
$this->assertEquals(53000, $resultNormal->summary->finalAmount);
// Then: 도서산간 지역 - 추가배송비 5,000원
$this->assertEquals(5000, $resultIsland->summary->extraShippingTotal);
$this->assertEquals(8000, $resultIsland->summary->totalShipping);
$this->assertEquals(58000, $resultIsland->summary->finalAmount);
// Then: 차이 = 5,000원 (추가배송비)
$this->assertEquals(5000, $resultIsland->summary->finalAmount - $resultNormal->summary->finalAmount);
}
/**
* 테스트 90: 배송지 변경으로 추가배송비 제거
*
* 도서산간 → 일반지역 변경
* 추가배송비 0원으로 변경
*/
public function test_complex_address_change_removes_extra_fee(): void
{
// Given: 추가배송비 설정된 정책
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 5000],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(50000, $policy);
// When: 도서산간 지역 배송
$inputIsland = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '63789')
);
$resultIsland = $this->service->calculate($inputIsland);
// When: 일반 지역으로 변경
$inputNormal = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '12345')
);
$resultNormal = $this->service->calculate($inputNormal);
// Then: 도서산간 지역 - 추가배송비 5,000원
$this->assertEquals(5000, $resultIsland->summary->extraShippingTotal);
$this->assertEquals(8000, $resultIsland->summary->totalShipping);
$this->assertEquals(58000, $resultIsland->summary->finalAmount);
// Then: 일반 지역 - 추가배송비 0원
$this->assertEquals(0, $resultNormal->summary->extraShippingTotal);
$this->assertEquals(3000, $resultNormal->summary->totalShipping);
$this->assertEquals(53000, $resultNormal->summary->finalAmount);
// Then: 차이 = -5,000원 (추가배송비 제거됨)
$this->assertEquals(-5000, $resultNormal->summary->finalAmount - $resultIsland->summary->finalAmount);
}
// ========================================
// Section 7.14: 다중 상품 쿠폰 적용 테스트
// ========================================
/**
* 테스트 95: 동일 상품에 2개 상품쿠폰 적용 (정액 + 정액)
*
* 시나리오: 상품 50,000원에 2개 정액 쿠폰 적용
* - 쿠폰A: 1,000원 정액 할인
* - 쿠폰B: 2,000원 정액 할인
* 기대: 총 3,000원 할인
*/
public function test_multiple_fixed_coupons_apply_to_same_product(): void
{
// Given: 50,000원 상품
[$product, $option] = $this->createProductWithOption(price: 50000);
// 쿠폰A: 1,000원 정액 할인
$couponIssueA = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
// 쿠폰B: 2,000원 정액 할인
$couponIssueB = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssueA->id, $couponIssueB->id],
);
// When
$result = $this->service->calculate($input);
// Then: 쿠폰 할인 합계 = 1,000 + 2,000 = 3,000원
$this->assertEquals(3000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(3000, $result->summary->productCouponDiscount);
$this->assertEquals(47000, $result->summary->finalAmount);
}
/**
* 테스트 96: 동일 상품에 2개 상품쿠폰 적용 (정률 + 정률)
*
* 시나리오: 상품 100,000원에 2개 정률 쿠폰 적용
* - 쿠폰A: 5% 할인
* - 쿠폰B: 10% 할인
*
* 참고: 현재 구현에서 각 쿠폰은 원가 기준으로 개별 계산됨 (순차 적용 아님)
*/
public function test_multiple_rate_coupons_apply_to_same_product(): void
{
// Given: 100,000원 상품
[$product, $option] = $this->createProductWithOption(price: 100000);
// 쿠폰A: 5% 정률 할인
$couponIssueA = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 5,
targetScope: CouponTargetScope::ALL
);
// 쿠폰B: 10% 정률 할인
$couponIssueB = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssueA->id, $couponIssueB->id],
);
// When
$result = $this->service->calculate($input);
// Then: 각 쿠폰은 원가 기준 개별 계산
// 쿠폰A: 100,000 × 5% = 5,000원
// 쿠폰B: 100,000 × 10% = 10,000원
// 총 할인: 15,000원
$this->assertEquals(15000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(15000, $result->summary->productCouponDiscount);
$this->assertEquals(85000, $result->summary->finalAmount);
}
/**
* 테스트 97: 동일 상품에 2개 상품쿠폰 적용 (정액 + 정률 혼합)
*
* 시나리오: 상품 50,000원에 정액 + 정률 쿠폰 적용
* - 쿠폰A: 5,000원 정액 할인
* - 쿠폰B: 10% 정률 할인
*
* 참고: 현재 구현에서 각 쿠폰은 원가 기준으로 개별 계산됨
*/
public function test_mixed_fixed_and_rate_coupons_apply_to_same_product(): void
{
// Given: 50,000원 상품
[$product, $option] = $this->createProductWithOption(price: 50000);
// 쿠폰A: 5,000원 정액 할인
$couponIssueA = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
targetScope: CouponTargetScope::ALL
);
// 쿠폰B: 10% 정률 할인
$couponIssueB = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssueA->id, $couponIssueB->id],
);
// When
$result = $this->service->calculate($input);
// Then: 각 쿠폰은 원가 기준 개별 계산
// 쿠폰A: 5,000원 정액
// 쿠폰B: 50,000 × 10% = 5,000원
// 총 할인: 10,000원
$this->assertEquals(10000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(10000, $result->summary->productCouponDiscount);
$this->assertEquals(40000, $result->summary->finalAmount);
}
/**
* 테스트 98: 다중 상품에 각각 다른 쿠폰 적용
*
* 시나리오: 상품A, 상품B에 서로 다른 쿠폰 적용
* - 상품A (30,000원): 쿠폰1 (전체 적용 1,000원)
* - 상품B (20,000원): 쿠폰1 (전체 적용 1,000원) + 쿠폰2 (상품B만 2,000원)
*/
public function test_different_coupons_for_different_products(): void
{
// Given: 상품A 30,000원, 상품B 20,000원
[$productA, $optionA] = $this->createProductWithOption(price: 30000);
[$productB, $optionB] = $this->createProductWithOption(price: 20000);
// 쿠폰1: 전체 상품 1,000원 정액 할인
$couponIssue1 = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
// 쿠폰2: 상품B만 2,000원 정액 할인
$couponIssue2 = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
targetScope: CouponTargetScope::PRODUCTS
);
$couponIssue2->coupon->includedProducts()->attach([$productB->id], ['type' => 'include']);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 1
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue1->id, $couponIssue2->id],
);
// When
$result = $this->service->calculate($input);
// Then:
// 상품A: 쿠폰1만 적용 = 1,000원 할인
// 상품B: 쿠폰1 + 쿠폰2 = 1,000 + 2,000 = 3,000원 할인
$this->assertEquals(1000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(3000, $result->items[1]->productCouponDiscountAmount);
$this->assertEquals(4000, $result->summary->productCouponDiscount);
$this->assertEquals(46000, $result->summary->finalAmount);
}
/**
* 테스트 99: 수량 있는 상품에 2개 쿠폰 적용
*
* 시나리오: 상품 10,000원 × 3개에 2개 쿠폰 적용
*
* 참고: 현재 구현에서 각 쿠폰은 원가 기준으로 개별 계산됨
*/
public function test_multiple_coupons_with_quantity(): void
{
// Given: 10,000원 상품 × 3개 = 30,000원
[$product, $option] = $this->createProductWithOption(price: 10000);
// 쿠폰A: 3,000원 정액 할인
$couponIssueA = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::ALL
);
// 쿠폰B: 5% 정률 할인
$couponIssueB = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 5,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 3
),
],
couponIssueIds: [$couponIssueA->id, $couponIssueB->id],
);
// When
$result = $this->service->calculate($input);
// Then: 각 쿠폰은 원가 기준 개별 계산
// 소계: 10,000 × 3 = 30,000원
// 쿠폰A: 3,000원 정액 × 수량 3 = 9,000원
// 쿠폰B: 30,000 × 5% = 1,500원
// 총 할인: 10,500원
$this->assertEquals(10500, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(10500, $result->summary->productCouponDiscount);
$this->assertEquals(19500, $result->summary->finalAmount);
}
/**
* 테스트 100: 다중 쿠폰 할인 합계 (상품 금액 초과 시 계산 방식)
*
* 시나리오: 상품 5,000원에 두 개의 큰 쿠폰 적용
*
* 참고: 현재 구현에서는 각 쿠폰의 할인액을 개별 계산하여 합산함
* 최종 결제 금액에서 음수 방지 처리됨 (finalAmount는 0 이상)
*/
public function test_multiple_coupons_discount_sum(): void
{
// Given: 5,000원 상품
[$product, $option] = $this->createProductWithOption(price: 5000);
// 쿠폰A: 3,000원 정액 할인
$couponIssueA = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::ALL
);
// 쿠폰B: 3,000원 정액 할인
$couponIssueB = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
],
couponIssueIds: [$couponIssueA->id, $couponIssueB->id],
);
// When
$result = $this->service->calculate($input);
// Then: 각 쿠폰은 개별 계산 (원가 기준)
// 쿠폰A: min(3,000, 5,000) = 3,000원
// 쿠폰B: min(3,000, 잔액 2,000) = 3,000원 (순차 적용 아님)
// 쿠폰 합계: 6,000원 (상품 금액 초과 가능)
$this->assertEquals(6000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(6000, $result->summary->productCouponDiscount);
// 최종 금액은 음수 방지로 0원 처리
$this->assertEquals(0, $result->summary->finalAmount);
}
/**
* 테스트: 상품쿠폰 적용 시 포맷된 할인 금액이 포함되는지 확인
*/
public function test_product_coupon_includes_formatted_discount_amounts(): void
{
// Given: 배송정책 (고정 3000원)
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
// 상품 (10,000원)
[$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy);
// 상품쿠폰: 1000원 고정 할인
$couponIssue = $this->createProductCouponWithIssue(
CouponTargetScope::ALL,
CouponDiscountType::FIXED,
1000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 포맷된 금액 필드 확인
$resultArray = $result->toArray();
$productCoupons = $resultArray['promotions']['product_promotions']['coupons'] ?? [];
$this->assertNotEmpty($productCoupons);
$this->assertArrayHasKey('total_discount_formatted', $productCoupons[0]);
$this->assertNotEmpty($productCoupons[0]['total_discount_formatted']);
$this->assertEquals(1000, $productCoupons[0]['total_discount']);
// applied_items에도 포맷된 금액 포함 확인
$appliedItems = $productCoupons[0]['applied_items'] ?? [];
$this->assertNotEmpty($appliedItems);
$this->assertArrayHasKey('discount_amount_formatted', $appliedItems[0]);
$this->assertNotEmpty($appliedItems[0]['discount_amount_formatted']);
}
/**
* 테스트: 주문쿠폰 적용 시 포맷된 할인 금액이 포함되는지 확인
*/
public function test_order_coupon_includes_formatted_discount_amounts(): void
{
// Given: 배송정책 (고정 3000원)
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
// 상품 (50,000원)
[$product, $option] = $this->createProductWithShippingPolicy(50000, $shippingPolicy);
// 주문쿠폰: 5000원 고정 할인
$orderCouponIssue = $this->createOrderCouponWithIssue(
CouponDiscountType::FIXED,
5000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$orderCouponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 포맷된 금액 필드 확인
$resultArray = $result->toArray();
$orderCoupons = $resultArray['promotions']['order_promotions']['coupons'] ?? [];
$this->assertNotEmpty($orderCoupons);
$this->assertArrayHasKey('total_discount_formatted', $orderCoupons[0]);
$this->assertNotEmpty($orderCoupons[0]['total_discount_formatted']);
$this->assertEquals(5000, $orderCoupons[0]['total_discount']);
// applied_items에도 포맷된 금액 포함 확인
$appliedItems = $orderCoupons[0]['applied_items'] ?? [];
$this->assertNotEmpty($appliedItems);
$this->assertArrayHasKey('discount_amount_formatted', $appliedItems[0]);
}
/**
* 테스트: 배송비쿠폰 적용 시 포맷된 할인 금액이 포함되는지 확인
*/
public function test_shipping_coupon_includes_formatted_discount_amounts(): void
{
// Given: 배송정책 (고정 5000원)
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 5000);
// 상품 (30,000원)
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
// 배송비쿠폰: 전액 무료 (5000원 고정 할인)
$shippingCouponIssue = $this->createShippingCouponWithIssue(
CouponDiscountType::FIXED,
5000,
null
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$shippingCouponIssue->id]
);
// When
$result = $this->service->calculate($input);
// Then: 포맷된 금액 필드 확인
$resultArray = $result->toArray();
$orderCoupons = $resultArray['promotions']['order_promotions']['coupons'] ?? [];
// 배송비 쿠폰은 order_promotions에 포함됨
$shippingCoupons = array_filter($orderCoupons, fn ($c) => $c['target_type'] === 'shipping_fee');
$shippingCoupons = array_values($shippingCoupons);
$this->assertNotEmpty($shippingCoupons);
$this->assertArrayHasKey('total_discount_formatted', $shippingCoupons[0]);
$this->assertNotEmpty($shippingCoupons[0]['total_discount_formatted']);
$this->assertEquals(5000, $shippingCoupons[0]['total_discount']);
}
// ========================================
// Section A: prepareItems 스냅샷 모드 (6개)
// ========================================
/**
* 테스트: 스냅샷 모드에서 아이템의 tax_status를 스냅샷 값으로 사용
*
* DB의 현재 tax_status가 변경되어도 스냅샷에 저장된 주문 시점의 값을 사용합니다.
*/
public function test_snapshot_mode_uses_item_snapshot_tax_status(): void
{
// Given: DB에 과세 상품 생성
$product = ProductFactory::new()->create([
'tax_status' => 'taxable',
'selling_price' => 50000,
'list_price' => 50000,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => 100,
]);
// 통상 모드로 먼저 계산 (taxable)
$normalResult = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1)],
));
$this->assertEquals(50000, $normalResult->summary->taxableAmount);
$this->assertEquals(0, $normalResult->summary->taxFreeAmount);
// DB에서 tax_status를 tax_free로 변경
$product->update(['tax_status' => 'tax_free']);
// 스냅샷에는 주문 시점의 taxable 상태를 보관
$productSnapshot = [
'id' => $product->id,
'name' => '테스트 상품',
'selling_price' => 50000,
'tax_status' => 'taxable', // 주문 시점: 과세
];
$optionSnapshot = [
'id' => $option->id,
'selling_price' => 50000,
'price_adjustment' => 0,
];
// When: 스냅샷 모드로 계산
$snapshotResult = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
],
metadata: ['snapshot_mode' => true],
));
// Then: DB가 tax_free로 바뀌었지만 스냅샷의 taxable 사용
$this->assertEquals(50000, $snapshotResult->summary->taxableAmount);
$this->assertEquals(0, $snapshotResult->summary->taxFreeAmount);
}
/**
* 테스트: 스냅샷 모드에서 아이템 무게를 스냅샷 값으로 사용 (RANGE_WEIGHT 배송비)
*
* DB에서 무게가 변경되어도 스냅샷의 주문 시점 무게로 배송비를 계산합니다.
*/
public function test_snapshot_mode_uses_item_snapshot_weight_for_shipping(): void
{
// Given: RANGE_WEIGHT 배송정책 (0~5000g: 3000원, 5000~10000g: 5000원)
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 5000, 'fee' => 3000],
['min' => 5000, 'max' => 10000, 'fee' => 5000],
],
],
);
// DB에 weight=10.0인 상품 생성
$product = ProductFactory::new()->create([
'tax_status' => 'taxable',
'selling_price' => 30000,
'list_price' => 30000,
'shipping_policy_id' => $policy->id,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => 100,
'weight' => 10.0,
]);
// 스냅샷에는 주문 시점 무게 (2.0kg = 2000g → 0~5000g 구간 → 3000원)
$productSnapshot = [
'id' => $product->id,
'name' => '테스트 상품',
'selling_price' => 30000,
'tax_status' => 'taxable',
];
$optionSnapshot = [
'id' => $option->id,
'selling_price' => 30000,
'price_adjustment' => 0,
'weight' => 2.0, // 주문 시점: 2kg (2000g → 0~5000g 구간)
'volume' => null,
];
$shippingPolicySnapshots = [
$option->id => [
'policy_id' => $policy->id,
'policy_snapshot' => [
'policy_name' => '테스트 배송정책',
'country_code' => 'KR',
'charge_policy' => 'range_weight',
'base_fee' => 0,
'ranges' => [
'tiers' => [
['min' => 0, 'max' => 5000, 'fee' => 3000],
['min' => 5000, 'max' => 10000, 'fee' => 5000],
],
],
],
],
];
// When: 스냅샷 모드로 계산
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
],
shippingAddress: new ShippingAddress(countryCode: 'KR'),
shippingPolicySnapshots: $shippingPolicySnapshots,
metadata: ['snapshot_mode' => true],
));
// Then: 스냅샷 무게 2.0kg → 0~5kg 구간 → 배송비 3000원
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 테스트: 스냅샷 모드에서 아이템 부피를 스냅샷 값으로 사용 (RANGE_VOLUME 배송비)
*
* DB에서 부피가 변경되어도 스냅샷의 주문 시점 부피로 배송비를 계산합니다.
*/
public function test_snapshot_mode_uses_item_snapshot_volume_for_shipping(): void
{
// Given: RANGE_VOLUME 배송정책 (0~5000: 2000원, 5000~10000: 4000원)
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME,
ranges: [
'tiers' => [
['min' => 0, 'max' => 5000, 'fee' => 2000],
['min' => 5000, 'max' => 10000, 'fee' => 4000],
],
],
);
// DB에 volume=7000인 상품 생성
$product = ProductFactory::new()->create([
'tax_status' => 'taxable',
'selling_price' => 30000,
'list_price' => 30000,
'shipping_policy_id' => $policy->id,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => 100,
'weight' => 1.0,
'volume' => 7000.0,
]);
// 스냅샷에는 주문 시점 부피 (3000cm³ → 0~5000 구간 → 2000원)
$productSnapshot = [
'id' => $product->id,
'name' => '테스트 상품',
'selling_price' => 30000,
'tax_status' => 'taxable',
];
$optionSnapshot = [
'id' => $option->id,
'selling_price' => 30000,
'price_adjustment' => 0,
'weight' => 1.0,
'volume' => 3000.0, // 주문 시점: 3000cm³ (0~5000 구간)
];
$shippingPolicySnapshots = [
$option->id => [
'policy_id' => $policy->id,
'policy_snapshot' => [
'policy_name' => '테스트 배송정책',
'country_code' => 'KR',
'charge_policy' => 'range_volume',
'base_fee' => 0,
'ranges' => [
'tiers' => [
['min' => 0, 'max' => 5000, 'fee' => 2000],
['min' => 5000, 'max' => 10000, 'fee' => 4000],
],
],
],
],
];
// When: 스냅샷 모드로 계산
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
],
shippingAddress: new ShippingAddress(countryCode: 'KR'),
shippingPolicySnapshots: $shippingPolicySnapshots,
metadata: ['snapshot_mode' => true],
));
// Then: 스냅샷 부피 50.0 → 0~100 구간 → 배송비 2000원
$this->assertEquals(2000, $result->summary->totalShipping);
}
/**
* 테스트: 스냅샷 모드에서 혼합 아이템 (스냅샷 + DB) 계산
*
* 하나는 스냅샷 데이터, 다른 하나는 DB에서 로드하여 함께 계산합니다.
*/
public function test_snapshot_mode_mixed_snapshot_and_db_items(): void
{
// Given: DB 상품 (통상 모드용)
[$dbProduct, $dbOption] = $this->createProductWithOption(price: 20000);
// 스냅샷 아이템 (DB에 없는 가상 상품)
$productSnapshot = [
'id' => 9999,
'name' => '스냅샷 상품',
'selling_price' => 30000,
'tax_status' => 'taxable',
];
$optionSnapshot = [
'id' => 8888,
'selling_price' => 30000,
'price_adjustment' => 0,
];
// When: 스냅샷 아이템 + DB 아이템 혼합 계산
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: 9999,
productOptionId: 8888,
quantity: 2,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
new CalculationItem(
productId: $dbProduct->id,
productOptionId: $dbOption->id,
quantity: 1,
),
],
metadata: ['snapshot_mode' => true],
));
// Then: 양쪽 모두 정상 계산
$this->assertCount(2, $result->items);
// 스냅샷 아이템: 30,000 × 2 = 60,000
$this->assertEquals(60000, $result->items[0]->subtotal);
// DB 아이템: 20,000 × 1 = 20,000
$this->assertEquals(20000, $result->items[1]->subtotal);
// 합계: 80,000
$this->assertEquals(80000, $result->summary->subtotal);
}
/**
* 테스트: 스냅샷 모드에서 마일리지 필드를 스냅샷 옵션에서 사용
*
* 옵션 스냅샷의 mileage_value/mileage_type으로 마일리지가 계산됩니다.
*/
public function test_snapshot_mode_mileage_fields_from_snapshot(): void
{
// Given: 스냅샷에 mileage_value=5.0 (정률 5%)
$productSnapshot = [
'id' => 9999,
'name' => '마일리지 테스트 상품',
'selling_price' => 100000,
'tax_status' => 'taxable',
];
$optionSnapshot = [
'id' => 8888,
'selling_price' => 100000,
'price_adjustment' => 0,
'mileage_value' => 5.0,
'mileage_type' => 'rate',
];
// When
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: 9999,
productOptionId: 8888,
quantity: 1,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
],
metadata: ['snapshot_mode' => true],
));
// Then: 마일리지 = 100,000 × 5% = 5,000
$this->assertEquals(5000, $result->items[0]->pointsEarning);
$this->assertEquals(5000, $result->summary->pointsEarning);
}
/**
* 테스트: 스냅샷 모드에서 productSnapshot이 null이면 DB 폴백
*
* 스냅샷이 없는 아이템은 통상 모드처럼 DB에서 상품 정보를 조회합니다.
*/
public function test_snapshot_mode_null_fallback_to_db(): void
{
// Given: DB에 상품 생성
[$product, $option] = $this->createProductWithOption(price: 40000);
// When: productSnapshot=null → DB 폴백
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 2,
productSnapshot: null, // 스냅샷 없음
optionSnapshot: null,
),
],
metadata: ['snapshot_mode' => true],
));
// Then: DB에서 조회하여 정상 계산
$this->assertCount(1, $result->items);
$this->assertEquals(40000, $result->items[0]->unitPrice);
$this->assertEquals(80000, $result->items[0]->subtotal);
}
// ========================================
// Section B: 배송비 스냅샷 모드 (2개)
// ========================================
/**
* 테스트: 스냅샷 배송정책의 RANGE_AMOUNT 구간별 수수료 적용
*
* 스냅샷에 저장된 구간별 요금으로 주문 금액에 맞는 배송비를 계산합니다.
*/
public function test_snapshot_shipping_policy_different_fee_ranges(): void
{
// Given: DB에 배송정책 생성 (실제와 다른 요금)
$policy = ShippingPolicy::create([
'name' => ['ko' => '구간 배송', 'en' => 'Range Shipping'],
'is_default' => false,
'is_active' => true,
]);
$policy->countrySettings()->create([
'country_code' => 'KR',
'shipping_method' => 'parcel',
'currency_code' => 'KRW',
'charge_policy' => ChargePolicyEnum::RANGE_AMOUNT,
'base_fee' => 0,
'ranges' => [
'ranges' => [
['min' => 0, 'max' => 50000, 'fee' => 9999],
['min' => 50000, 'max' => 999999, 'fee' => 0],
],
],
'is_active' => true,
]);
// 스냅샷: 주문 시점의 요금 구간 (0~50000: 3000원, 50000+: 0원)
$productSnapshot = [
'id' => 9999,
'name' => '테스트 상품',
'selling_price' => 30000,
'tax_status' => 'taxable',
];
$optionSnapshot = [
'id' => 8888,
'selling_price' => 30000,
'price_adjustment' => 0,
];
$shippingPolicySnapshots = [
8888 => [
'policy_id' => $policy->id,
'policy_snapshot' => [
'policy_name' => '구간 배송',
'country_code' => 'KR',
'charge_policy' => 'range_amount',
'base_fee' => 0,
'ranges' => [
'tiers' => [
['min' => 0, 'max' => 50000, 'fee' => 3000],
['min' => 50000, 'max' => 999999, 'fee' => 0],
],
],
],
],
];
// When: 30,000원 주문 (0~50000 구간 → 스냅샷 3000원)
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: 9999,
productOptionId: 8888,
quantity: 1,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
],
shippingAddress: new ShippingAddress(countryCode: 'KR'),
shippingPolicySnapshots: $shippingPolicySnapshots,
metadata: ['snapshot_mode' => true],
));
// Then: DB 요금(9999)이 아닌 스냅샷 요금(3000) 적용
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 테스트: 스냅샷 배송정책에 도서산간 추가배송비 포함
*
* 스냅샷에 저장된 도서산간 설정으로 추가배송비를 계산합니다.
*/
public function test_snapshot_shipping_policy_extra_fee_from_snapshot(): void
{
// Given: DB에 배송정책 생성 (도서산간 미설정)
$policy = ShippingPolicy::create([
'name' => ['ko' => '도서산간 배송', 'en' => 'Island Shipping'],
'is_default' => false,
'is_active' => true,
]);
$policy->countrySettings()->create([
'country_code' => 'KR',
'shipping_method' => 'parcel',
'currency_code' => 'KRW',
'charge_policy' => ChargePolicyEnum::FIXED,
'base_fee' => 3000,
'extra_fee_enabled' => false, // DB에서는 비활성
'is_active' => true,
]);
// 스냅샷: 주문 시점에는 도서산간 활성이었음
$productSnapshot = [
'id' => 9999,
'name' => '테스트 상품',
'selling_price' => 30000,
'tax_status' => 'taxable',
];
$optionSnapshot = [
'id' => 8888,
'selling_price' => 30000,
'price_adjustment' => 0,
];
$shippingPolicySnapshots = [
8888 => [
'policy_id' => $policy->id,
'policy_snapshot' => [
'policy_name' => '도서산간 배송',
'country_code' => 'KR',
'charge_policy' => 'fixed',
'base_fee' => 3000,
'extra_fee_enabled' => true, // 스냅샷: 도서산간 활성
'extra_fee_multiply' => false,
'extra_fee_settings' => [
['zipcode' => '63000-63644', 'fee' => 5000],
],
],
],
];
// When: 도서산간 우편번호로 계산
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: 9999,
productOptionId: 8888,
quantity: 1,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
],
shippingAddress: new ShippingAddress(countryCode: 'KR', zipcode: '63100'),
shippingPolicySnapshots: $shippingPolicySnapshots,
metadata: ['snapshot_mode' => true],
));
// Then: 기본 3,000 + 도서산간 5,000 = 8,000원
$this->assertEquals(3000, $result->summary->baseShippingTotal);
$this->assertEquals(5000, $result->summary->extraShippingTotal);
$this->assertEquals(8000, $result->summary->totalShipping);
}
// ========================================
// Section C: 쿠폰 스냅샷 모드 (3개)
// ========================================
/**
* 테스트: 스냅샷 모드에서도 최소 주문금액 검증은 수행
*
* 스냅샷 쿠폰의 min_order_amount 미달 시 쿠폰이 적용되지 않습니다.
*/
public function test_snapshot_coupon_min_order_still_checked(): void
{
// Given: 쿠폰 생성 (최소 주문금액 30,000원)
$user = User::factory()->create();
$coupon = Coupon::create([
'name' => ['ko' => '최소주문 쿠폰', 'en' => 'Min Order Coupon'],
'description' => ['ko' => '테스트', 'en' => 'Test'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 5000,
'min_order_amount' => 30000,
'target_scope' => CouponTargetScope::ALL,
'is_combinable' => true,
'valid_from' => now()->subDay(),
'valid_to' => now()->addDays(30),
]);
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'coupon_code' => 'MINORDER'.uniqid(),
'status' => CouponIssueRecordStatus::USED,
'issued_at' => now(),
'expired_at' => now()->addDays(30),
]);
// 스냅샷: 20,000원 상품 (최소 주문금액 30,000원 미달)
$productSnapshot = [
'id' => 9999,
'name' => '테스트 상품',
'selling_price' => 20000,
'tax_status' => 'taxable',
];
$optionSnapshot = [
'id' => 8888,
'selling_price' => 20000,
'price_adjustment' => 0,
];
$couponSnapshots = [
$couponIssue->id => [
'discount_type' => 'fixed',
'discount_value' => 5000,
'min_order_amount' => 30000, // 최소 주문금액 30,000원
'target_type' => 'product_amount',
'target_scope' => 'all',
],
];
// When: 주문금액 20,000원 < 최소 30,000원
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: 9999,
productOptionId: 8888,
quantity: 1,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
],
couponIssueIds: [$couponIssue->id],
metadata: [
'snapshot_mode' => true,
'coupon_snapshots' => $couponSnapshots,
],
));
// Then: 최소 주문금액 미달 → 쿠폰 미적용
$appliedCoupons = $result->promotions->getAllCoupons();
$this->assertEmpty($appliedCoupons);
// 검증 오류가 기록됨
$this->assertNotEmpty($result->validationErrors);
}
/**
* 테스트: 스냅샷 모드에서 만료된 쿠폰도 적용 (유효기간/범위 검증 스킵)
*
* 환불 재계산 시 이미 사용된 쿠폰의 만료/범위 검증을 스킵합니다.
*/
public function test_snapshot_coupon_scope_skipped(): void
{
// Given: 만료된 쿠폰 생성
$user = User::factory()->create();
$coupon = Coupon::create([
'name' => ['ko' => '만료 쿠폰', 'en' => 'Expired Coupon'],
'description' => ['ko' => '테스트', 'en' => 'Test'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 3000,
'min_order_amount' => 0,
'target_scope' => CouponTargetScope::ALL,
'is_combinable' => true,
'valid_from' => now()->subDays(60),
'valid_to' => now()->subDays(30), // 30일 전 만료
]);
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'coupon_code' => 'EXPIRED'.uniqid(),
'status' => CouponIssueRecordStatus::USED,
'issued_at' => now()->subDays(60),
'expired_at' => now()->subDays(30), // 만료
]);
$productSnapshot = [
'id' => 9999,
'name' => '테스트 상품',
'selling_price' => 50000,
'tax_status' => 'taxable',
];
$optionSnapshot = [
'id' => 8888,
'selling_price' => 50000,
'price_adjustment' => 0,
];
$couponSnapshots = [
$couponIssue->id => [
'discount_type' => 'fixed',
'discount_value' => 3000,
'min_order_amount' => 0,
'target_type' => 'product_amount',
'target_scope' => 'all',
],
];
// When: 스냅샷 모드로 만료 쿠폰 적용
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: 9999,
productOptionId: 8888,
quantity: 1,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
],
couponIssueIds: [$couponIssue->id],
metadata: [
'snapshot_mode' => true,
'coupon_snapshots' => $couponSnapshots,
],
));
// Then: 만료 쿠폰이지만 스냅샷 모드에서 적용됨
$appliedCoupons = $result->promotions->getAllCoupons();
$this->assertNotEmpty($appliedCoupons);
$this->assertEquals(3000, $appliedCoupons[0]->totalDiscount);
}
/**
* 테스트: 스냅샷 모드에서 상품쿠폰 + 주문쿠폰 + 배송비쿠폰 복합 적용
*
* 3종류 쿠폰이 모두 스냅샷 모드에서 동시 적용됩니다.
*/
public function test_snapshot_coupon_multiple_types_combined(): void
{
// Given: 배송정책
$policy = ShippingPolicy::create([
'name' => ['ko' => '기본 배송', 'en' => 'Standard'],
'is_default' => false,
'is_active' => true,
]);
$policy->countrySettings()->create([
'country_code' => 'KR',
'shipping_method' => 'parcel',
'currency_code' => 'KRW',
'charge_policy' => ChargePolicyEnum::FIXED,
'base_fee' => 5000,
'is_active' => true,
]);
$user = User::factory()->create();
// 상품쿠폰: 2,000원 할인
$productCoupon = Coupon::create([
'name' => ['ko' => '상품쿠폰', 'en' => 'Product Coupon'],
'description' => ['ko' => '테스트', 'en' => 'Test'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 2000,
'min_order_amount' => 0,
'target_scope' => CouponTargetScope::ALL,
'is_combinable' => true,
'valid_from' => now()->subDays(60),
'valid_to' => now()->subDays(1), // 만료
]);
$productCouponIssue = CouponIssue::create([
'coupon_id' => $productCoupon->id,
'user_id' => $user->id,
'coupon_code' => 'PROD'.uniqid(),
'status' => CouponIssueRecordStatus::USED,
'issued_at' => now()->subDays(60),
'expired_at' => now()->subDays(1),
]);
// 주문쿠폰: 3,000원 할인
$orderCoupon = Coupon::create([
'name' => ['ko' => '주문쿠폰', 'en' => 'Order Coupon'],
'description' => ['ko' => '테스트', 'en' => 'Test'],
'target_type' => CouponTargetType::ORDER_AMOUNT,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 3000,
'min_order_amount' => 0,
'target_scope' => CouponTargetScope::ALL,
'is_combinable' => true,
'valid_from' => now()->subDays(60),
'valid_to' => now()->subDays(1), // 만료
]);
$orderCouponIssue = CouponIssue::create([
'coupon_id' => $orderCoupon->id,
'user_id' => $user->id,
'coupon_code' => 'ORDER'.uniqid(),
'status' => CouponIssueRecordStatus::USED,
'issued_at' => now()->subDays(60),
'expired_at' => now()->subDays(1),
]);
// 배송비쿠폰: 전액 할인
$shippingCoupon = Coupon::create([
'name' => ['ko' => '배송비쿠폰', 'en' => 'Shipping Coupon'],
'description' => ['ko' => '테스트', 'en' => 'Test'],
'target_type' => CouponTargetType::SHIPPING_FEE,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 5000,
'min_order_amount' => 0,
'target_scope' => CouponTargetScope::ALL,
'is_combinable' => true,
'valid_from' => now()->subDays(60),
'valid_to' => now()->subDays(1), // 만료
]);
$shippingCouponIssue = CouponIssue::create([
'coupon_id' => $shippingCoupon->id,
'user_id' => $user->id,
'coupon_code' => 'SHIP'.uniqid(),
'status' => CouponIssueRecordStatus::USED,
'issued_at' => now()->subDays(60),
'expired_at' => now()->subDays(1),
]);
$productSnapshot = [
'id' => 9999,
'name' => '테스트 상품',
'selling_price' => 50000,
'tax_status' => 'taxable',
];
$optionSnapshot = [
'id' => 8888,
'selling_price' => 50000,
'price_adjustment' => 0,
];
$couponSnapshots = [
$productCouponIssue->id => [
'discount_type' => 'fixed',
'discount_value' => 2000,
'min_order_amount' => 0,
'target_type' => 'product_amount',
'target_scope' => 'all',
],
$orderCouponIssue->id => [
'discount_type' => 'fixed',
'discount_value' => 3000,
'min_order_amount' => 0,
'target_type' => 'order_amount',
'target_scope' => 'all',
],
$shippingCouponIssue->id => [
'discount_type' => 'fixed',
'discount_value' => 5000,
'min_order_amount' => 0,
'target_type' => 'shipping_fee',
'target_scope' => 'all',
],
];
$shippingPolicySnapshots = [
8888 => [
'policy_id' => $policy->id,
'policy_snapshot' => [
'policy_name' => '기본 배송',
'country_code' => 'KR',
'charge_policy' => 'fixed',
'base_fee' => 5000,
],
],
];
// When: 3종류 쿠폰 모두 적용
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: 9999,
productOptionId: 8888,
quantity: 1,
productSnapshot: $productSnapshot,
optionSnapshot: $optionSnapshot,
),
],
couponIssueIds: [$productCouponIssue->id, $orderCouponIssue->id, $shippingCouponIssue->id],
shippingAddress: new ShippingAddress(countryCode: 'KR'),
shippingPolicySnapshots: $shippingPolicySnapshots,
metadata: [
'snapshot_mode' => true,
'coupon_snapshots' => $couponSnapshots,
],
));
// Then: 모든 쿠폰이 적용됨
$allCoupons = $result->promotions->getAllCoupons();
$this->assertCount(3, $allCoupons);
// 상품쿠폰 2,000원 할인
$this->assertEquals(2000, $result->summary->productCouponDiscount);
// 주문쿠폰 3,000원 할인
$this->assertEquals(3000, $result->summary->orderCouponDiscount);
// 배송비쿠폰 5,000원 할인
$this->assertEquals(5000, $result->summary->shippingDiscount);
// 총 할인: 2,000 + 3,000 + 5,000 = 10,000
$this->assertEquals(10000, $result->summary->totalDiscount + $result->summary->shippingDiscount);
}
// ========================================================================
// 정액 상품쿠폰 × 수량 반영 테스트
// ========================================================================
/**
* 테스트: 정액 상품쿠폰 — 단일 상품, 수량 2개
*
* 시나리오: 20,000원 × 2 = 40,000원, 정액 1,000원 쿠폰
* 기대: 1,000 × 2 = 2,000원 할인
*/
public function test_fixed_product_coupon_quantity_2_single_product(): void
{
[$product, $option] = $this->createProductWithOption(price: 20000);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 2
),
],
couponIssueIds: [$couponIssue->id],
);
$result = $this->service->calculate($input);
// 1,000원 × 2개 = 2,000원
$this->assertEquals(2000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(2000, $result->summary->productCouponDiscount);
$this->assertEquals(38000, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 — 다중 상품, 다른 수량
*
* 시나리오: A(20,000원 × 3), B(10,000원 × 2) = 총 80,000원, 정액 1,000원 쿠폰 전체 적용
* 기대: A=1,000×3=3,000원, B=1,000×2=2,000원, 합계 5,000원
*/
public function test_fixed_product_coupon_multiple_products_different_quantities(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 3
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 2
),
],
couponIssueIds: [$couponIssue->id],
);
$result = $this->service->calculate($input);
// A: 1,000 × 3 = 3,000원, B: 1,000 × 2 = 2,000원
$this->assertEquals(3000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(2000, $result->items[1]->productCouponDiscountAmount);
$this->assertEquals(5000, $result->summary->productCouponDiscount);
$this->assertEquals(75000, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 — 적용대상 상품만 수량 할인
*
* 시나리오: A(10,000원 × 2), B(15,000원 × 3), C(20,000원 × 1)
* 쿠폰: 정액 2,000원, A,B만 적용
* 기대: A=2,000×2=4,000원, B=2,000×3=6,000원, C=0, 합계 10,000원
*/
public function test_fixed_product_coupon_specific_products_with_quantity(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 10000);
[$productB, $optionB] = $this->createProductWithOption(price: 15000);
[$productC, $optionC] = $this->createProductWithOption(price: 20000);
$couponIssue = $this->createProductCouponWithIssue(
targetScope: CouponTargetScope::PRODUCTS,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
targetIds: [$productA->id, $productB->id]
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 2
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 3
),
new CalculationItem(
productId: $productC->id,
productOptionId: $optionC->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id],
);
$result = $this->service->calculate($input);
// A: 2,000×2=4,000, B: 2,000×3=6,000, C: 0
$this->assertEquals(4000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(6000, $result->items[1]->productCouponDiscountAmount);
$this->assertEquals(0, $result->items[2]->productCouponDiscountAmount);
$this->assertEquals(10000, $result->summary->productCouponDiscount);
// 소계: 20,000+45,000+20,000=85,000, 할인 10,000 → 75,000
$this->assertEquals(75000, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 — 할인액이 소계 초과 시 소계 한도
*
* 시나리오: 500원 × 3 = 1,500원, 정액 1,000원 쿠폰
* 기대: 1,000 × 3 = 3,000이지만, 소계 1,500원 한도 → 1,500원
*/
public function test_fixed_product_coupon_quantity_discount_capped_by_subtotal(): void
{
[$product, $option] = $this->createProductWithOption(price: 500);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 3
),
],
couponIssueIds: [$couponIssue->id],
);
$result = $this->service->calculate($input);
// 1,000×3=3,000이지만 소계 1,500원이 한도
$this->assertEquals(1500, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(1500, $result->summary->productCouponDiscount);
$this->assertEquals(0, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 — 수량 5개, 대량 할인
*
* 시나리오: 30,000원 × 5 = 150,000원, 정액 10,000원 쿠폰
* 기대: 10,000 × 5 = 50,000원
*/
public function test_fixed_product_coupon_quantity_5_large_discount(): void
{
[$product, $option] = $this->createProductWithOption(price: 30000);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 10000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 5
),
],
couponIssueIds: [$couponIssue->id],
);
$result = $this->service->calculate($input);
// 10,000 × 5 = 50,000원
$this->assertEquals(50000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(50000, $result->summary->productCouponDiscount);
$this->assertEquals(100000, $result->summary->finalAmount);
}
/**
* 테스트: 정률 상품쿠폰은 수량 영향 없음 (소계 기준)
*
* 시나리오: 10,000원 × 3 = 30,000원, 정률 10% 쿠폰
* 기대: 30,000 × 10% = 3,000원 (수량 무관, 소계 기준)
*/
public function test_rate_product_coupon_not_multiplied_by_quantity(): void
{
[$product, $option] = $this->createProductWithOption(price: 10000);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 3
),
],
couponIssueIds: [$couponIssue->id],
);
$result = $this->service->calculate($input);
// 30,000 × 10% = 3,000원 (정률은 소계 기준)
$this->assertEquals(3000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(3000, $result->summary->productCouponDiscount);
$this->assertEquals(27000, $result->summary->finalAmount);
}
/**
* 테스트: 정액+정률 혼합 쿠폰 — 수량 반영
*
* 시나리오: 50,000원 × 2 = 100,000원
* 정액 5,000원 쿠폰 + 정률 10% 쿠폰
* 기대: 정액 5,000×2=10,000원, 정률 100,000×10%=10,000원, 합계 20,000원
*/
public function test_mixed_fixed_and_rate_coupons_with_quantity(): void
{
[$product, $option] = $this->createProductWithOption(price: 50000);
$couponIssueA = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
targetScope: CouponTargetScope::ALL
);
$couponIssueB = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 2
),
],
couponIssueIds: [$couponIssueA->id, $couponIssueB->id],
);
$result = $this->service->calculate($input);
// 정액: 5,000×2=10,000, 정률: 100,000×10%=10,000, 합계 20,000
$this->assertEquals(20000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(20000, $result->summary->productCouponDiscount);
$this->assertEquals(80000, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 + 주문쿠폰 — 수량 반영
*
* 시나리오: A(40,000원 × 2), B(60,000원 × 1) = 총 140,000원
* 상품쿠폰: 정액 3,000원 전체, 주문쿠폰: 정률 5%
* 기대:
* 상품쿠폰: A=3,000×2=6,000, B=3,000×1=3,000 → 합계 9,000원
* 주문쿠폰: (140,000-9,000) × 5% = 6,550원
*/
public function test_fixed_product_coupon_with_order_coupon_quantity(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 40000);
[$productB, $optionB] = $this->createProductWithOption(price: 60000);
$productCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::ALL
);
$orderCoupon = $this->createOrderCouponWithIssue(
discountType: CouponDiscountType::RATE,
discountValue: 5,
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 2
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
],
couponIssueIds: [$productCoupon->id, $orderCoupon->id],
);
$result = $this->service->calculate($input);
// 상품쿠폰: A=3,000×2=6,000, B=3,000×1=3,000 → 합계 9,000
$this->assertEquals(6000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(3000, $result->items[1]->productCouponDiscountAmount);
$this->assertEquals(9000, $result->summary->productCouponDiscount);
// 주문쿠폰: (140,000-9,000) × 5% = 6,550원
$this->assertEquals(6550, $result->summary->orderCouponDiscount);
// 최종: 140,000 - 9,000 - 6,550 = 124,450
$this->assertEquals(124450, $result->summary->finalAmount);
}
/**
* 테스트: 복합 — 상품쿠폰(정액) + 주문쿠폰 + 배송비 + 마일리지, 수량 반영
*
* 시나리오: A(50,000원 × 2), B(50,000원 × 1) = 총 150,000원
* 상품쿠폰: 정액 10,000원, 주문쿠폰: 정률 5%, 배송쿠폰: 정률 50%
* 배송비: 5,000원, 마일리지: 10,000원
*/
public function test_complex_full_scenario_all_discounts_with_quantity(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 5000
);
[$productA, $optionA] = $this->createProductWithShippingPolicy(50000, $shippingPolicy);
[$productB, $optionB] = $this->createProductWithShippingPolicy(50000, $shippingPolicy);
$productCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 10000,
targetScope: CouponTargetScope::ALL
);
$orderCoupon = $this->createOrderCouponWithIssue(
discountType: CouponDiscountType::RATE,
discountValue: 5,
);
$shippingCoupon = $this->createShippingCouponWithIssue(
discountType: CouponDiscountType::RATE,
discountValue: 50,
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 2
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
],
couponIssueIds: [$productCoupon->id, $orderCoupon->id, $shippingCoupon->id],
usePoints: 10000,
);
$result = $this->service->calculate($input);
// 상품쿠폰: A=10,000×2=20,000, B=10,000×1=10,000 → 합계 30,000
$this->assertEquals(20000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(10000, $result->items[1]->productCouponDiscountAmount);
$this->assertEquals(30000, $result->summary->productCouponDiscount);
// 주문쿠폰: (150,000-30,000) × 5% = 6,000
$this->assertEquals(6000, $result->summary->orderCouponDiscount);
// 배송비: 5,000원 (동일 정책 그룹), 배송쿠폰: 5,000×50%=2,500
$this->assertEquals(5000, $result->summary->totalShipping);
$this->assertEquals(2500, $result->summary->shippingDiscount);
// 마일리지: 10,000원
$this->assertEquals(10000, $result->summary->pointsUsed);
// 최종: 150,000 - 30,000 - 6,000 + 5,000 - 2,500 - 10,000 = 106,500
$this->assertEquals(106500, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 — 다중 정액 쿠폰, 수량 반영
*
* 시나리오: 50,000원 × 2 = 100,000원
* 쿠폰A: 정액 1,000원, 쿠폰B: 정액 2,000원
* 기대: A=1,000×2=2,000, B=2,000×2=4,000 → 합계 6,000원
*/
public function test_multiple_fixed_coupons_with_quantity(): void
{
[$product, $option] = $this->createProductWithOption(price: 50000);
$couponIssueA = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
$couponIssueB = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 2
),
],
couponIssueIds: [$couponIssueA->id, $couponIssueB->id],
);
$result = $this->service->calculate($input);
// 쿠폰A: 1,000×2=2,000, 쿠폰B: 2,000×2=4,000, 합계 6,000
$this->assertEquals(6000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(6000, $result->summary->productCouponDiscount);
$this->assertEquals(94000, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 — 서로 다른 상품에 서로 다른 쿠폰, 수량 반영
*
* 시나리오: A(30,000원 × 2), B(20,000원 × 3)
* 쿠폰1: 정액 1,000원 전체, 쿠폰2: 정액 2,000원 B만
* 기대:
* 쿠폰1: A=1,000×2=2,000, B=1,000×3=3,000 → 5,000
* 쿠폰2: B=2,000×3=6,000
* A 합계: 2,000, B 합계: 9,000, 총 합계: 11,000
*/
public function test_different_fixed_coupons_for_different_products_with_quantity(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 30000);
[$productB, $optionB] = $this->createProductWithOption(price: 20000);
$couponIssue1 = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
$couponIssue2 = $this->createProductCouponWithIssue(
targetScope: CouponTargetScope::PRODUCTS,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
targetIds: [$productB->id]
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 2
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 3
),
],
couponIssueIds: [$couponIssue1->id, $couponIssue2->id],
);
$result = $this->service->calculate($input);
// 쿠폰1: A=1,000×2=2,000, B=1,000×3=3,000
// 쿠폰2: B=2,000×3=6,000
// A 합계: 2,000, B 합계: 9,000
$this->assertEquals(2000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(9000, $result->items[1]->productCouponDiscountAmount);
$this->assertEquals(11000, $result->summary->productCouponDiscount);
// 소계: 60,000+60,000=120,000, 할인 11,000 → 109,000
$this->assertEquals(109000, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 + 주문쿠폰(정액) — 주문쿠폰은 수량 미반영
*
* 시나리오: 20,000원 × 3 = 60,000원
* 상품쿠폰: 정액 2,000원, 주문쿠폰: 정액 5,000원
* 기대:
* 상품쿠폰: 2,000×3=6,000
* 주문쿠폰: 5,000 (수량 무관, 주문 전체 1회)
*/
public function test_fixed_product_coupon_quantity_order_coupon_no_quantity(): void
{
[$product, $option] = $this->createProductWithOption(price: 20000);
$productCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
targetScope: CouponTargetScope::ALL
);
$orderCoupon = $this->createOrderCouponWithIssue(
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 3
),
],
couponIssueIds: [$productCoupon->id, $orderCoupon->id],
);
$result = $this->service->calculate($input);
// 상품쿠폰: 2,000×3=6,000
$this->assertEquals(6000, $result->summary->productCouponDiscount);
// 주문쿠폰: 5,000 (수량 무관)
$this->assertEquals(5000, $result->summary->orderCouponDiscount);
// 최종: 60,000-6,000-5,000=49,000
$this->assertEquals(49000, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 — 최대할인금액 제한 + 수량
*
* 시나리오: 10,000원 × 5 = 50,000원
* 정률 50% 쿠폰, 최대할인 5,000원
* 기대: 50,000×50%=25,000이지만 최대 5,000원 (정률은 수량 영향 없음)
*/
public function test_rate_product_coupon_max_discount_with_quantity(): void
{
[$product, $option] = $this->createProductWithOption(price: 10000);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 50,
targetScope: CouponTargetScope::ALL,
maxDiscount: 5000
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 5
),
],
couponIssueIds: [$couponIssue->id],
);
$result = $this->service->calculate($input);
// 50,000 × 50% = 25,000 → 최대 5,000원
$this->assertEquals(5000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(5000, $result->summary->productCouponDiscount);
$this->assertEquals(45000, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 — 배송정책 수량계산과 함께
*
* 시나리오: 10,000원 × 4 = 40,000원, 수량당 배송비 1,000원
* 정액 1,000원 쿠폰 전체 적용
* 기대: 상품쿠폰 1,000×4=4,000, 배송비 4×1,000=4,000
*/
public function test_fixed_product_coupon_with_per_quantity_shipping_and_quantity(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000
);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 4
),
],
couponIssueIds: [$couponIssue->id],
);
$result = $this->service->calculate($input);
// 상품쿠폰: 1,000×4=4,000
$this->assertEquals(4000, $result->summary->productCouponDiscount);
// 배송비: 4×1,000=4,000
$this->assertEquals(4000, $result->summary->totalShipping);
// 최종: 40,000-4,000+4,000=40,000
$this->assertEquals(40000, $result->summary->finalAmount);
}
/**
* 테스트: 정액 상품쿠폰 — 과세/면세 혼합 + 수량
*
* 시나리오: A(과세 10,000원 × 2), B(면세 20,000원 × 1)
* 정액 1,000원 쿠폰 전체 적용
* 기대: A=1,000×2=2,000, B=1,000×1=1,000 → 합계 3,000
*/
public function test_fixed_product_coupon_mixed_tax_status_with_quantity(): void
{
[$productA, $optionA] = $this->createProductWithTaxStatus(10000, ProductTaxStatus::TAXABLE);
[$productB, $optionB] = $this->createProductWithTaxStatus(20000, ProductTaxStatus::TAX_FREE);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::ALL
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $productA->id,
productOptionId: $optionA->id,
quantity: 2
),
new CalculationItem(
productId: $productB->id,
productOptionId: $optionB->id,
quantity: 1
),
],
couponIssueIds: [$couponIssue->id],
);
$result = $this->service->calculate($input);
// A: 1,000×2=2,000, B: 1,000×1=1,000
$this->assertEquals(2000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(1000, $result->items[1]->productCouponDiscountAmount);
$this->assertEquals(3000, $result->summary->productCouponDiscount);
// 과세금액: A할인 후 소계 = 20,000 - 2,000 = 18,000
$this->assertEquals(18000, $result->summary->taxableAmount);
// 면세금액: B할인 후 소계 = 20,000 - 1,000 = 19,000
$this->assertEquals(19000, $result->summary->taxFreeAmount);
}
/**
* 테스트: 스냅샷 모드에서도 정액 상품쿠폰 수량 반영
*
* 시나리오: 50,000원 × 2 = 100,000원 (스냅샷 모드)
* 정액 2,000원 쿠폰
* 기대: 2,000×2=4,000원
*/
public function test_snapshot_mode_fixed_product_coupon_with_quantity(): void
{
[$product, $option] = $this->createProductWithOption(price: 50000);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
targetScope: CouponTargetScope::ALL
);
$productSnapshot = [
'id' => $product->id,
'name' => '테스트 상품',
'selling_price' => 50000,
'tax_status' => ProductTaxStatus::TAXABLE->value,
];
$optionSnapshot = [
'id' => $option->id,
'selling_price' => 50000,
'price_adjustment' => 0,
];
$input = new CalculationInput(
items: [
CalculationItem::fromArray([
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 2,
'product_snapshot' => $productSnapshot,
'option_snapshot' => $optionSnapshot,
]),
],
couponIssueIds: [$couponIssue->id],
metadata: [
'snapshot_mode' => true,
'coupon_snapshots' => [
$couponIssue->id => [
'discount_type' => 'fixed',
'discount_value' => 2000,
'min_order_amount' => 0,
'max_discount_amount' => 0,
'target_type' => 'product_amount',
'target_scope' => 'all',
'applied_items' => [
['product_option_id' => $option->id, 'discount_amount' => 2000],
],
],
],
],
);
$result = $this->service->calculate($input);
// 스냅샷 모드에서도 정액 2,000×2=4,000원
$this->assertEquals(4000, $result->items[0]->productCouponDiscountAmount);
$this->assertEquals(4000, $result->summary->productCouponDiscount);
$this->assertEquals(96000, $result->summary->finalAmount);
}
/**
* 삭제된 배송정책 참조 시 에러 없이 배송비 0원 처리
*
* 입력: shipping_policy_id가 존재하지만 실제 ShippingPolicy 레코드가 삭제된 상품
* 기대: TypeError 없이 배송비 0원, 정상 계산 완료
*/
public function test_it_handles_deleted_shipping_policy_gracefully(): void
{
// Given: 배송정책을 생성 후 삭제하여, 상품이 존재하지 않는 policy_id를 참조하게 함
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
// 배송정책 삭제 (상품의 shipping_policy_id는 남아있음)
$shippingPolicy->countrySettings()->delete();
$shippingPolicy->delete();
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1
),
]
);
// When: 에러 없이 계산 완료되어야 함
$result = $this->service->calculate($input);
// Then: 배송비 0원, 상품 금액은 정상
$this->assertEquals(0, $result->summary->totalShipping);
$this->assertEquals(30000, $result->summary->subtotal);
}
// ========================================
// Section 7.15: 구간 경계 · 단위 환산 회귀 (공개 #94)
//
// 계약 ① 구간 매칭 = 상한 포함 + 사다리(ladder). min 은 표시 전용이며 매칭에 쓰지 않는다.
// 계약 ② 단위 = 상품 옵션은 g/cm³ 저장, 배송정책 입력은 kg/L, 환산은 엔진 한 곳에서.
// 계약 ③ 무음 0원 금지 = 잔여 미매칭 경로는 0원을 유지하되 경고 로그를 남긴다.
// ========================================
/**
* 수량 구간의 상한은 포함이다 (공개 #94 본문 재현).
*
* 관리자 화면·요약 문자열이 "1~5개: 3,000 / 6개~: 5,000" 으로 약속한 대로
* 경계값 5 가 첫 구간에 매칭되어야 한다. 기존 반개(半開) 매칭에서는 5 가
* 어느 구간에도 걸리지 않아 무음 0원(무료배송)이 되었다.
*/
public function test_it_matches_quantity_range_with_inclusive_upper_bound(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_QUANTITY,
ranges: [
'tiers' => [
['min' => 1, 'max' => 5, 'fee' => 3000],
['min' => 6, 'max' => null, 'fee' => 5000],
],
]
);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy);
$expectations = [1 => 3000, 4 => 3000, 5 => 3000, 6 => 5000, 100 => 5000];
foreach ($expectations as $quantity => $expectedFee) {
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: $quantity
),
]
));
$this->assertEquals(
$expectedFee,
$result->summary->totalShipping,
"수량 {$quantity} 의 배송비가 기대와 다릅니다."
);
}
}
/**
* 반개(半開) 형태로 저장된 기존 데이터에서도 사다리 매칭이 정확해야 한다.
*
* 다음 구간의 min 이 현재 구간의 max 와 같은 형태(0~2 / 2~5 / 5~)에서
* 경계값 2.0kg 은 "~2kg" 구간에 속한다 (상한 포함).
*/
public function test_it_matches_half_open_legacy_tiers_by_upper_bound(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 2, 'fee' => 3000],
['min' => 2, 'max' => 5, 'fee' => 5000],
['min' => 5, 'max' => null, 'fee' => 8000],
],
]
);
// 2,000g = 2.0kg (경계값)
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2000, 0);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 소수 경계(2.5kg)를 가진 연속형 구간도 정확히 매칭되어야 한다.
*/
public function test_it_matches_decimal_tier_boundary(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 2.5, 'fee' => 3000],
['min' => 2.5, 'max' => null, 'fee' => 5000],
],
]
);
// 2,500g = 2.5kg (소수 경계값)
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2500, 0);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* tiers 가 비어 있으면 0원을 유지하되 경고 로그를 남긴다 (계약 ③).
*/
public function test_it_logs_warning_when_range_tiers_are_empty(): void
{
Log::spy();
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_QUANTITY,
ranges: ['tiers' => []]
);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(0, $result->summary->totalShipping);
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains((string) $message, '구간별 배송비'))
->atLeast()->once();
}
/**
* 배송정책이 삭제된 경우 0원을 유지하되 경고 로그를 남긴다 (계약 ③).
*/
public function test_it_logs_warning_when_shipping_policy_is_missing(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
$shippingPolicy->countrySettings()->delete();
$shippingPolicy->delete();
Log::spy();
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(0, $result->summary->totalShipping);
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains((string) $message, '배송정책'))
->atLeast()->once();
}
/**
* 수신자 국가의 설정이 없으면 0원을 유지하되 경고 로그를 남긴다 (계약 ③).
*/
public function test_it_logs_warning_when_country_setting_is_missing(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
countryCode: 'KR'
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
Log::spy();
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
shippingAddress: new ShippingAddress(countryCode: 'US')
));
$this->assertEquals(0, $result->summary->totalShipping);
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains((string) $message, '국가별 배송 설정'))
->atLeast()->once();
}
/**
* 무게 구간 정책은 상품의 g 값을 kg 으로 환산해 비교한다 (계약 ②).
*
* 500g 상품 → 0.5kg → "~2kg" 구간. 기존 코드는 ×1000 이중 변환으로
* 500,000 이 되어 최상위 구간에 오적중했다.
*/
public function test_it_converts_grams_to_kilograms_for_range_weight(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 2, 'fee' => 3000],
['min' => 2, 'max' => null, 'fee' => 7000],
],
]
);
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 500, 0);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(3000, $result->summary->totalShipping);
}
/**
* 부피 구간 정책은 상품의 cm³ 값을 L 로 환산해 비교한다 (계약 ②).
*
* 1,000cm³ = 1L → "~50L" 구간.
*/
public function test_it_converts_cubic_centimeters_to_liters_for_range_volume(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME,
ranges: [
'tiers' => [
['min' => 0, 'max' => 50, 'fee' => 5000],
['min' => 50, 'max' => null, 'fee' => 9000],
],
]
);
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 0, 1000);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(5000, $result->summary->totalShipping);
}
/**
* 무게당 정책은 kg 단위로 계산한다 (계약 ②).
*
* 500g = 0.5kg, 1kg당 1,000원 → ceil(0.5/1) × 1,000 = 1,000원.
* 기존 코드는 500 ÷ 1 = 500단위 → 500,000원이었다 (공개 #94 재현값).
*/
public function test_it_charges_per_kilogram_for_per_weight_policy(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 1000,
ranges: ['unit_value' => 1]
);
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 500, 0);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(1000, $result->summary->totalShipping);
}
/**
* 부피당 정책은 L 단위로 계산한다 (계약 ②).
*
* 1,000cm³ = 1L, 10L당 2,000원 → ceil(1/10) × 2,000 = 2,000원.
*/
public function test_it_charges_per_liter_for_per_volume_policy(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_VOLUME,
baseFee: 2000,
ranges: ['unit_value' => 10]
);
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 0, 1000);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(2000, $result->summary->totalShipping);
}
/**
* 부피무게 구간은 실무게(kg)와 부피무게(kg)를 같은 단위로 비교한다 (계약 ②).
*
* 500g(0.5kg) + 6,000cm³ ÷ 6,000 = 1kg → 부피무게 1kg 채택 → "~5kg" 구간.
*/
public function test_it_compares_actual_and_volumetric_weight_in_kilograms(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME_WEIGHT,
ranges: [
'volume_weight_divisor' => 6000,
'tiers' => [
['min' => 0, 'max' => 5, 'fee' => 3500],
['min' => 5, 'max' => null, 'fee' => 9000],
],
]
);
[$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 500, 6000);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(3500, $result->summary->totalShipping);
}
/**
* 소수 기본배송비는 절삭이 아니라 반올림한다 (검토 메모 12).
*
* base_fee 999.50 × 1단위 → 1,000원 (기존 (int) 절삭은 999원).
*/
public function test_it_rounds_decimal_base_fee_instead_of_truncating(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 1]
);
$shippingPolicy->countrySettings()->first()->update(['base_fee' => 999.5]);
[$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(1000, $result->summary->totalShipping);
}
/**
* 도서산간 중복 부과는 수량이 아니라 배송 단위 수를 기준으로 한다 (검토 메모 14).
*
* PER_WEIGHT 1kg당 2,000원, 500g 상품 3개 → 1.5kg → 단위 수 2 (수량 3이 아님).
* 기본 2 × 2,000 = 4,000원, 도서산간 2 × 3,000 = 6,000원.
*/
public function test_it_multiplies_extra_fee_by_charge_units_not_quantity(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 1],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
],
extraFeeMultiply: true
);
[$product, $option] = $this->createProductWithDimensions(10000, $shippingPolicy, 500, 0);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3),
],
shippingAddress: new ShippingAddress(zipcode: '63123')
));
$this->assertEquals(4000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(6000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
$this->assertEquals(10000, $result->summary->totalShipping);
}
/**
* 저장된 단위값이 0 이어도 0 으로 나누지 않고 1 단위로 계산한다.
*
* 신규 저장은 FormRequest 의 min:0.01 이 막지만, 그 규칙이 생기기 전에 저장된 정책과
* 시더·업그레이드처럼 FormRequest 를 거치지 않는 경로는 0 을 그대로 들고 있을 수 있다.
* 이 값이 나눗셈에 닿으면 계산 전체가 DivisionByZeroError 로 죽는다.
*
* PER_WEIGHT base 2,000원 · unit_value 0 → 1 로 클램프 → 1.5kg → ceil(1.5/1)=2 → 4,000원.
*/
public function test_it_clamps_zero_unit_value_instead_of_dividing_by_zero(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 0]
);
[$product, $option] = $this->createProductWithDimensions(10000, $shippingPolicy, 1500, 0);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(4000, $result->summary->totalShipping);
}
/**
* 저장된 단위값이 음수여도 1 단위로 클램프한다.
*
* 음수는 나눗셈이 성립해 예외가 나지 않는 대신 단위 수가 음수가 되어, 배송비가 음수이거나
* 0 원으로 뒤집힌다 — 예외 없이 금액만 틀리는 무음 결함이라 0 과 함께 고정한다.
*/
public function test_it_clamps_negative_unit_value_instead_of_inverting_the_fee(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => -1]
);
[$product, $option] = $this->createProductWithDimensions(10000, $shippingPolicy, 1500, 0);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(4000, $result->summary->totalShipping);
}
/**
* 도서산간 곱의 단위 수 계산도 같은 클램프를 적용한다.
*
* 단위 수는 기본 배송비와 도서산간 곱 두 곳에서 각각 나눗셈을 수행하므로, 한쪽만 막으면
* 도서산간을 켠 정책에서만 계산이 죽는다.
*/
public function test_it_clamps_zero_unit_value_in_extra_fee_charge_units(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 0],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
],
extraFeeMultiply: true
);
[$product, $option] = $this->createProductWithDimensions(10000, $shippingPolicy, 1500, 0);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
shippingAddress: new ShippingAddress(zipcode: '63123')
));
$this->assertEquals(4000, $result->items[0]->appliedShippingPolicy->shippingAmount);
$this->assertEquals(6000, $result->items[0]->appliedShippingPolicy->extraShippingAmount);
}
/**
* 외부 API 가 음수 배송비를 반환하면 기본 배송비로 폴백하고 경고를 남긴다 (검토 메모 11).
*/
public function test_it_falls_back_to_base_fee_when_api_returns_negative_fee(): void
{
Http::fake([
'https://shipping-api.example.com/calculate' => Http::response(['shipping_fee' => -500], 200),
]);
Log::spy();
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::API,
baseFee: 3000,
apiEndpoint: 'https://shipping-api.example.com/calculate'
);
[$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy);
$result = $this->service->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
));
$this->assertEquals(3000, $result->summary->totalShipping);
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains((string) $message, '음수'))
->atLeast()->once();
}
}