적립 포인트 절사가 코드에 1점 버림으로 고정돼 있어 운영자가 조정할 수 없었다. 언어/통화 설정의 환율 절사를 따라가는 방안을 먼저 검토했으나, 그쪽은 외화 환산 분기에서만 적용되어 기본 통화 상점에는 따라갈 값 자체가 없다. 적립은 기본 통화 원장에 정수로 확정 기록되는 값이라 성격이 다르므로 마일리지 통화별 규칙에 신설했다. - earn_rounding_unit(1/10/100) + earn_rounding_method(floor/round/ceil) - 기본값은 종전 하드코딩과 정확히 같은 금액을 산출 — 기존 쇼핑몰 적립액 무변화 - 주문 계산·옵션 분할·부분취소 안분 전 경로에 동일 기준 적용 - 주문 시점 값을 스냅샷으로 보관 — 이후 기준을 바꿔도 과거 주문이 소급 변경되지 않는다 - 설정 UI·검증·다국어(ko/en/ja)·API 문서 동반, 기존 사이트는 업그레이드 스텝이 명시 기입 함께 처리: 마일리지 내역 목록 테스트가 첫 컬럼 field 를 `no` 로 단언해 실패하던 것을 현재 레이아웃 값(`number`)에 맞춰 정정했다. 이 브랜치와 무관한 stale 케이스다.
169 lines
6.6 KiB
PHP
169 lines
6.6 KiB
PHP
<?php
|
||
|
||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||
|
||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
|
||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
|
||
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||
use PHPUnit\Framework\Attributes\Test;
|
||
|
||
/**
|
||
* 부분수량 분할 시 적립 포인트 안분 절사 테스트
|
||
*
|
||
* 부분취소·부분 구매확정은 주문옵션 레코드를 분할하고 금액을 비율 안분한다. 적립 포인트만은
|
||
* 원장이 정수로 확정하는 값인데 안분이 `round(..., 2)` 였다 — 3개 중 1개를 분할하면
|
||
* 781 × (1/3) = 260.33 처럼 **원장에 존재할 수 없는 소수점 포인트**가 옵션 행에 남았고,
|
||
* 그 값이 곧 적립 목표액(`earnForOrderOption` 의 target)이 되어 실제 지급 가능한 정수
|
||
* 포인트와 어긋났다.
|
||
*
|
||
* 안분에도 주문 시점 절사 기준을 적용하고, 잔여분은 `원본 − 분할` 로 두어 총액을 보존한다.
|
||
*/
|
||
class OrderOptionSplitEarnedPointsRoundingTest extends ModuleTestCase
|
||
{
|
||
private OrderOptionService $service;
|
||
|
||
private string $settingsPath;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
|
||
$this->service = app(OrderOptionService::class);
|
||
$this->settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
|
||
|
||
if (! is_dir($this->settingsPath)) {
|
||
mkdir($this->settingsPath, 0755, true);
|
||
}
|
||
|
||
file_put_contents($this->settingsPath.'/mileage.json', json_encode([
|
||
'enabled' => true,
|
||
'default_earn_rate' => 1,
|
||
'earn_trigger' => 'confirmed',
|
||
// 분할 자체를 관측하기 위해 적립 트리거는 뒤로 미룬다 (분할 → 적립 순서 간섭 배제)
|
||
'earn_delay_days' => 30,
|
||
'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
|
||
{
|
||
$file = $this->settingsPath.'/mileage.json';
|
||
if (file_exists($file)) {
|
||
unlink($file);
|
||
}
|
||
|
||
parent::tearDown();
|
||
}
|
||
|
||
/**
|
||
* 적립 포인트가 실린 배송완료 주문을 만듭니다.
|
||
*
|
||
* @param int $quantity 옵션 수량
|
||
* @param float $earnedPoints 옵션 적립 포인트
|
||
* @param array|null $mileagePolicySnapshot 주문 시점 마일리지 정책 스냅샷
|
||
* @return Order 생성된 주문
|
||
*/
|
||
private function makeOrder(int $quantity, float $earnedPoints, ?array $mileagePolicySnapshot = null): Order
|
||
{
|
||
$order = OrderFactory::new()->create([
|
||
'order_status' => OrderStatusEnum::DELIVERED,
|
||
'currency' => 'KRW',
|
||
'mileage_policy_snapshot' => $mileagePolicySnapshot,
|
||
]);
|
||
|
||
OrderOptionFactory::new()->forOrder($order)->create([
|
||
'option_status' => OrderStatusEnum::DELIVERED,
|
||
'delivered_at' => now()->subDay(),
|
||
'quantity' => $quantity,
|
||
'subtotal_earned_points_amount' => $earnedPoints,
|
||
]);
|
||
|
||
return $order->fresh();
|
||
}
|
||
|
||
#[Test]
|
||
public function 분할된_적립_포인트에_소수점이_남지_않는다(): void
|
||
{
|
||
// 781점 / 3개 → 1개 분할 시 260.333... 점
|
||
$order = $this->makeOrder(3, 781);
|
||
$option = $order->options->first();
|
||
|
||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
|
||
|
||
$points = Order::find($order->id)->options
|
||
->pluck('subtotal_earned_points_amount')
|
||
->map(fn ($v) => (float) $v);
|
||
|
||
foreach ($points as $value) {
|
||
$this->assertSame(
|
||
(float) (int) $value,
|
||
$value,
|
||
"분할 안분 결과에 소수점 포인트가 남았다 ({$value}) — 원장은 정수 포인트만 기록한다"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[Test]
|
||
public function 분할해도_적립_총액이_보존된다(): void
|
||
{
|
||
$order = $this->makeOrder(3, 781);
|
||
$option = $order->options->first();
|
||
|
||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
|
||
|
||
$total = Order::find($order->id)->options->sum(fn ($o) => (float) $o->subtotal_earned_points_amount);
|
||
|
||
$this->assertSame(781.0, $total, '분할 전후 적립 총액이 달라졌다 — 잔여분은 원본 − 분할이어야 한다');
|
||
}
|
||
|
||
#[Test]
|
||
public function 안분도_주문_시점_절사_기준을_따른다(): void
|
||
{
|
||
// 주문 시점 기준: 10점 올림. 781 × (1/3) = 260.33 → 270
|
||
$order = $this->makeOrder(3, 781, [
|
||
'usable' => true,
|
||
'currency' => 'KRW',
|
||
'rule' => [
|
||
MileageRounding::UNIT_KEY => '10',
|
||
MileageRounding::METHOD_KEY => 'ceil',
|
||
],
|
||
]);
|
||
$option = $order->options->first();
|
||
|
||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
|
||
|
||
$fresh = Order::find($order->id)->options;
|
||
$split = $fresh->firstWhere('option_status', OrderStatusEnum::CANCELLED);
|
||
$remaining = $fresh->firstWhere('option_status', OrderStatusEnum::DELIVERED);
|
||
|
||
$this->assertSame(270.0, (float) $split->subtotal_earned_points_amount);
|
||
$this->assertSame(511.0, (float) $remaining->subtotal_earned_points_amount, '잔여분 = 원본 − 분할');
|
||
}
|
||
|
||
#[Test]
|
||
public function 스냅샷이_없는_구형_주문은_1점_버림으로_안분된다(): void
|
||
{
|
||
// 이 기능 이전 주문에는 절사 기준이 없다 — 폴백이 정수화를 보장해야 한다.
|
||
$order = $this->makeOrder(3, 781, null);
|
||
$option = $order->options->first();
|
||
|
||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
|
||
|
||
$fresh = Order::find($order->id)->options;
|
||
$split = $fresh->firstWhere('option_status', OrderStatusEnum::CANCELLED);
|
||
$remaining = $fresh->firstWhere('option_status', OrderStatusEnum::DELIVERED);
|
||
|
||
$this->assertSame(260.0, (float) $split->subtotal_earned_points_amount);
|
||
$this->assertSame(521.0, (float) $remaining->subtotal_earned_points_amount);
|
||
}
|
||
}
|