Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/OrderSeeder.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

1604 lines
67 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Database\Seeders\Sample;
use App\Models\User;
use App\Traits\HasSeederCounts;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderAddressFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderShippingFactory;
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\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\OrderShipping;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOptionValue;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicyCountrySetting;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\SequenceService;
/**
* 주문 더미 데이터 시더
*/
class OrderSeeder extends Seeder
{
use HasSeederCounts;
/**
* 주문 생성 중 소진된 쿠폰 기록 [['user_id' => int, 'coupon_id' => int], ...]
*
* @var array<int, array{user_id: int, coupon_id: int}>
*/
private array $usedCouponRecords = [];
/**
* 생성할 기본 주문 수
*/
private const ORDER_COUNT = 100;
/**
* 주문을 배정할 회원 풀 크기 (주문을 더 많은 회원에 분산)
*/
private const ORDER_USER_POOL = 40;
/**
* 상태별 최소 주문 수
*/
private const MIN_PER_STATUS = 5;
/**
* 비회원 주문 비율 (%)
*/
private const GUEST_ORDER_PERCENTAGE = 20;
/**
* 쿠폰 적용 주문 비율 (%)
*/
private const COUPON_ORDER_PERCENTAGE = 40;
/**
* 주문 상태별 비율 (합계 100)
*/
private array $statusDistribution = [
'pending_order' => 3,
'pending_payment' => 7,
'payment_complete' => 20,
'shipping_hold' => 2,
'preparing' => 15,
'shipping_ready' => 5,
'shipping' => 13,
'delivered' => 20,
'confirmed' => 10,
'cancelled' => 5,
];
/**
* 쿠폰 우선 배정 상태 (이 상태들을 먼저 생성하여 쿠폰 확보)
*/
private const COUPON_PRIORITY_STATUSES = [
'payment_complete', 'preparing', 'shipping_ready', 'shipping', 'delivered', 'confirmed',
];
/**
* 시더 실행
*/
public function run(): void
{
$this->command->info('주문 더미 데이터 생성을 시작합니다.');
$this->deleteExistingOrders();
$this->createOrders();
$this->replenishAvailableCoupons();
$count = Order::count();
$this->command->info("주문 더미 데이터 {$count}건이 성공적으로 생성되었습니다.");
}
/**
* 기존 주문 삭제
*/
private function deleteExistingOrders(): void
{
$deletedCount = Order::withTrashed()->count();
if ($deletedCount > 0) {
// 쿠폰 발급 상태 복원 (주문 삭제 전에 실행 — order_id FK가 ON DELETE SET NULL이므로)
// used 상태인 모든 쿠폰을 available로 복원 (고아 상태 포함)
$resetCount = CouponIssue::where('status', CouponIssueRecordStatus::USED)
->update([
'status' => CouponIssueRecordStatus::AVAILABLE,
'used_at' => null,
'order_id' => null,
'discount_amount' => null,
]);
// 관련 데이터 삭제 (역순으로)
OrderShipping::query()->delete();
OrderPayment::query()->delete();
OrderAddress::query()->delete();
OrderOption::query()->delete();
Order::withTrashed()->forceDelete();
$this->command->warn("기존 주문 관련 데이터 {$deletedCount}건을 삭제했습니다. (쿠폰 {$resetCount}건 복원)");
}
}
/**
* 주문 생성
*/
private function createOrders(): void
{
// 사용자 목록 가져오기 (없으면 생성)
// 주문을 더 많은 회원에 분산시키기 위해 일반 회원(관리자 제외) 풀을 넓게 사용한다.
// 풀이 넓을수록 $users->random() 로 배정되는 주문이 더 많은 회원에 퍼지고,
// 후행 마일리지 시더(실주문 기반 적립/사용 정합)도 그만큼 많은 회원에 분포된다.
$users = User::whereDoesntHave('roles', fn ($q) => $q->where('identifier', 'admin'))
->inRandomOrder()
->take(self::ORDER_USER_POOL)
->get();
// 일반 회원이 부족하면 전체 회원으로 폴백, 그래도 없으면 생성
if ($users->isEmpty()) {
$users = User::take(self::ORDER_USER_POOL)->get();
}
if ($users->isEmpty()) {
$this->command->line(' - 사용자가 없어 '.self::ORDER_USER_POOL.'명 생성합니다.');
$users = User::factory()->count(self::ORDER_USER_POOL)->create();
}
$this->command->line(" - 주문 배정 회원 풀: {$users->count()}명");
// 상품 옵션 목록 가져오기 (없으면 경고)
$productOptions = ProductOption::with('product')->take(100)->get();
if ($productOptions->isEmpty()) {
$this->command->warn('상품 옵션이 없습니다. ProductSeeder를 먼저 실행하는 것을 권장합니다.');
$this->command->warn('상품 없이 주문을 생성합니다.');
}
// 상태별 주문 수 계산
$orderCount = $this->getSeederCount('orders', self::ORDER_COUNT);
$statusCounts = $this->calculateStatusCounts($orderCount);
$createdCount = 0;
$progressBar = $this->command->getOutput()->createProgressBar($orderCount);
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%');
// 사용자별 사용 가능한 쿠폰 발급 목록 캐시
$availableCouponIssuesByUser = $this->loadAvailableCouponIssuesByUser();
$couponCount = 0;
$guestCount = 0;
// 쿠폰 우선 배정: 쿠폰이 필요한 상태를 먼저 생성하여 쿠폰 재고 확보
$priorityStatuses = [];
$normalStatuses = [];
foreach ($statusCounts as $status => $count) {
if (in_array($status, self::COUPON_PRIORITY_STATUSES)) {
$priorityStatuses[$status] = $count;
} else {
$normalStatuses[$status] = $count;
}
}
$orderedStatusCounts = array_merge($priorityStatuses, $normalStatuses);
foreach ($orderedStatusCounts as $status => $count) {
for ($i = 0; $i < $count; $i++) {
// 비회원 주문 비율 적용 (쿠폰 우선 상태는 비회원 비율 절반으로)
$guestChance = in_array($status, self::COUPON_PRIORITY_STATUSES)
? (int) (self::GUEST_ORDER_PERCENTAGE / 2)
: self::GUEST_ORDER_PERCENTAGE;
$isGuest = rand(1, 100) <= $guestChance;
$user = $isGuest ? null : $users->random();
// 쿠폰 적용 여부 결정 (회원 주문 + 취소 아닌 경우만)
// 쿠폰 우선 상태는 적용 비율 80%로 상향
$couponChance = in_array($status, self::COUPON_PRIORITY_STATUSES)
? 80
: self::COUPON_ORDER_PERCENTAGE;
$applyCoupon = ! $isGuest
&& $status !== 'cancelled'
&& rand(1, 100) <= $couponChance
&& isset($availableCouponIssuesByUser[$user?->id]);
$this->createOrder(
$user,
OrderStatusEnum::from($status),
$productOptions,
$applyCoupon ? $availableCouponIssuesByUser[$user->id] : null,
);
if ($applyCoupon) {
$couponCount++;
}
if ($isGuest) {
$guestCount++;
}
$createdCount++;
$progressBar->advance();
}
}
$progressBar->finish();
$this->command->newLine();
$this->command->info(' - 회원 주문: '.($createdCount - $guestCount)."건, 비회원 주문: {$guestCount}건, 쿠폰 적용: {$couponCount}건");
}
/**
* 상태별 주문 수 계산
*
* @param int $orderCount 총 주문 수
* @return array<string, int>
*/
private function calculateStatusCounts(int $orderCount): array
{
$counts = [];
$total = 0;
foreach ($this->statusDistribution as $status => $percentage) {
$count = max(self::MIN_PER_STATUS, (int) round($orderCount * $percentage / 100));
$counts[$status] = $count;
$total += $count;
}
// 최소 보장으로 총 수가 늘어났으면 orderCount 자체를 확장
// (비율 기반 상태에서 줄이지 않고, 늘어난 총합을 그대로 사용)
if ($total > $orderCount) {
return $counts;
}
// 반올림으로 인한 오차 보정 (delivered에 추가 — 가장 비율 높은 상태)
if ($total < $orderCount) {
$counts['delivered'] += $orderCount - $total;
}
return $counts;
}
/**
* 단일 주문 생성
*
* @param User|null $user 사용자 (null이면 비회원 주문)
* @param OrderStatusEnum $status 주문 상태
* @param Collection $productOptions 상품 옵션 목록
* @param Collection|null $userCouponIssues 사용자의 사용 가능 쿠폰 발급 목록
*/
private function createOrder(
?User $user,
OrderStatusEnum $status,
$productOptions,
$userCouponIssues = null,
): void {
// 주문 날짜 (상태에 따라 다르게 설정)
$orderedAt = $this->getOrderedAtByStatus($status);
// 주문 기본 정보 생성
$order = $this->createOrderByStatus($user, $status, $orderedAt);
// 주문 옵션 생성 (1~3개 랜덤)
$optionCount = rand(1, 3);
$orderOptions = $this->createOrderOptions($order, $productOptions, $optionCount, $status);
// 쿠폰 적용 (쿠폰 발급 목록이 있는 경우)
$pendingShippingCoupons = [];
if ($userCouponIssues !== null && $orderOptions->isNotEmpty()) {
$pendingShippingCoupons = $this->applyCouponsToOrderOptions($order, $orderOptions, $userCouponIssues);
}
// 배송지 주소 생성
$this->createOrderAddress($order);
// 배송 정보 생성 (결제 완료 이상 상태)
$hasShipping = ! $status->isBeforeShipping() || $status === OrderStatusEnum::PREPARING;
if ($hasShipping) {
$this->createOrderShippings($order, $orderOptions, $status);
}
// 배송비 쿠폰 적용 (배송 레코드 생성 후)
if (! empty($pendingShippingCoupons) && $hasShipping) {
$this->applyShippingCouponsToShippings($order, $pendingShippingCoupons);
}
// 주문 금액 재계산 (배송 생성 + 배송비 쿠폰 적용 후)
$this->recalculateOrderAmount($order, $orderOptions);
// 결제 정보 생성 (결제 대기 이상 상태)
if (! $status->isBeforePayment()) {
$this->createOrderPayment($order, $status);
}
// 적립(PURCHASE_EARN) 발행은 OrderSeeder 가 아니라 MileageSeeder 의 책임이다.
// MileageSeeder 가 OrderSeeder 뒤에 실행되며 기존 거래를 전량 정리 후 배송완료/구매확정
// 주문의 적립액에서 거래를 재발행한다 (잔액 lot·시간순·캐시 정합 일괄 관리). 여기서 발행하면
// 중복/덮어쓰기가 된다. 따라서 적립 보정은 MileageSeeder 측에서 처리한다.
}
/**
* 상태에 따른 주문일 반환
*/
private function getOrderedAtByStatus(OrderStatusEnum $status): Carbon
{
return match ($status) {
OrderStatusEnum::PENDING_ORDER, OrderStatusEnum::PENDING_PAYMENT => now()->subHours(rand(1, 48)),
OrderStatusEnum::PAYMENT_COMPLETE, OrderStatusEnum::SHIPPING_HOLD, OrderStatusEnum::PREPARING => now()->subDays(rand(1, 3)),
OrderStatusEnum::SHIPPING_READY, OrderStatusEnum::SHIPPING => now()->subDays(rand(2, 5)),
OrderStatusEnum::DELIVERED => now()->subDays(rand(5, 10)),
OrderStatusEnum::CONFIRMED => now()->subDays(rand(10, 30)),
OrderStatusEnum::CANCELLED => now()->subDays(rand(1, 14)),
default => now(),
};
}
/**
* 상태에 따라 주문 생성
*
* @param User|null $user 사용자 (null이면 비회원 주문)
* @param Carbon $orderedAt
*/
private function createOrderByStatus(
?User $user,
OrderStatusEnum $status,
$orderedAt,
): Order {
$factory = $user
? OrderFactory::new()->forUser($user)
: OrderFactory::new()->forGuest();
// 상태별 Factory 적용
$factory = match ($status) {
OrderStatusEnum::PENDING_ORDER, OrderStatusEnum::PENDING_PAYMENT => $factory->pendingPayment(),
OrderStatusEnum::PAYMENT_COMPLETE, OrderStatusEnum::SHIPPING_HOLD, OrderStatusEnum::PREPARING, OrderStatusEnum::SHIPPING_READY => $factory->paid(),
OrderStatusEnum::SHIPPING => $factory->shipping(),
OrderStatusEnum::DELIVERED => $factory->delivered(),
OrderStatusEnum::CONFIRMED => $factory->confirmed(),
OrderStatusEnum::CANCELLED => $factory->cancelled(),
};
// 모바일 주문 30% 확률
if (rand(1, 100) <= 30) {
$factory = $factory->mobile();
}
// 시퀀스 서비스로 주문번호 생성 (프로덕션과 동일한 로직)
$orderNumber = app(SequenceService::class)->generateCode(SequenceType::ORDER);
$attributes = [
'order_number' => $orderNumber,
'order_status' => $status,
'ordered_at' => $orderedAt,
'currency_snapshot' => $this->buildCurrencySnapshot(),
];
// 취소 주문은 취소일시(native cancelled_at) 기록 — 주문일 이후 시점 (MP02)
if ($status === OrderStatusEnum::CANCELLED) {
$attributes['cancelled_at'] = (clone $orderedAt)->addHours(rand(1, 48));
}
return $factory->create($attributes);
}
/**
* 주문 옵션 생성
*
* @param Collection $productOptions
*/
private function createOrderOptions(
Order $order,
$productOptions,
int $count,
OrderStatusEnum $orderStatus
): Collection {
$orderOptions = collect();
// 실제 상품 옵션이 있으면 사용
if ($productOptions->isNotEmpty()) {
$selectedOptions = $productOptions->random(min($count, $productOptions->count()));
foreach ($selectedOptions as $productOption) {
$quantity = rand(1, 3);
$product = $productOption->product;
// 추가옵션 스냅샷 (상품에 추가옵션이 있으면 30% 확률로 선택 동결)
// 추가옵션 단위 합계는 단가에 가산된다 (OrderProcessingService와 동일).
[$additionalOptionsSnapshot, $additionalOptionsTotal] = $this->buildOrderOptionAdditionalOptions($product);
$basePrice = $productOption->product->selling_price + ($productOption->price_adjustment ?? 0);
$unitPrice = $basePrice + $additionalOptionsTotal;
$subtotalPrice = $quantity * $unitPrice;
// 할인은 쿠폰/코드 미적용 시 0 (출처 없는 유령 할인 방지)
$discountAmount = 0;
$optionStatus = $this->getOrderOptionStatusByOrderStatus($orderStatus);
// 다국어 원본 직접 저장
$optionNameI18n = $productOption->option_name ?? [];
$optionValueI18n = $this->buildMultilingualOptionValue($productOption->option_values);
$productNameJson = is_array($product->name) ? $product->name : ['ko' => $product->getLocalizedName(), 'en' => $product->getLocalizedName()];
$finalAmount = $subtotalPrice - $discountAmount;
$orderOption = OrderOption::create([
'order_id' => $order->id,
'product_id' => $productOption->product_id,
'product_option_id' => $productOption->id,
'option_status' => $optionStatus,
'is_stock_deducted' => $this->isStockDeductedForStatus($optionStatus),
'source_type' => 'order',
'sku' => $productOption->option_code,
'product_name' => $productNameJson,
'product_option_name' => $optionNameI18n,
'option_name' => $optionNameI18n,
'option_value' => $optionValueI18n,
'quantity' => $quantity,
'unit_weight' => $product->weight ?? 0.5,
'unit_volume' => 0.01,
'subtotal_weight' => ($product->weight ?? 0.5) * $quantity,
'subtotal_volume' => 0.01 * $quantity,
'unit_price' => $unitPrice,
'additional_options_total' => $additionalOptionsTotal,
'subtotal_price' => $subtotalPrice,
'subtotal_discount_amount' => $discountAmount,
'product_coupon_discount_amount' => 0,
'order_coupon_discount_amount' => 0,
'coupon_discount_amount' => 0,
'code_discount_amount' => 0,
'subtotal_points_used_amount' => 0,
'subtotal_deposit_used_amount' => 0,
'subtotal_paid_amount' => $finalAmount,
'subtotal_tax_amount' => round($finalAmount / 11, 2),
'subtotal_tax_free_amount' => 0,
'subtotal_earned_points_amount' => round($finalAmount * 0.01, 2),
'product_snapshot' => $product->toSnapshotArray(),
'option_snapshot' => $productOption->toSnapshotArray(),
'additional_options_snapshot' => $additionalOptionsSnapshot,
'promotions_applied_snapshot' => null,
// 다중 통화 필드 (OrderProcessingService와 동일 형식)
'mc_unit_price' => $this->buildMultiCurrencyAmount($unitPrice),
'mc_additional_options_total' => $additionalOptionsTotal > 0 ? $this->buildMultiCurrencyAmount($additionalOptionsTotal) : null,
'mc_subtotal_price' => $this->buildMultiCurrencyAmount($subtotalPrice),
'mc_product_coupon_discount_amount' => $this->buildMultiCurrencyAmount(0),
'mc_order_coupon_discount_amount' => $this->buildMultiCurrencyAmount(0),
'mc_coupon_discount_amount' => $this->buildMultiCurrencyAmount(0),
'mc_code_discount_amount' => $this->buildMultiCurrencyAmount(0),
'mc_subtotal_points_used_amount' => $this->buildMultiCurrencyAmount(0),
'mc_subtotal_deposit_used_amount' => $this->buildMultiCurrencyAmount(0),
'mc_subtotal_tax_amount' => $this->buildMultiCurrencyAmount(round($finalAmount / 11, 2)),
'mc_subtotal_tax_free_amount' => $this->buildMultiCurrencyAmount(0),
'mc_final_amount' => $this->buildMultiCurrencyAmount($finalAmount),
]);
$orderOptions->push($orderOption);
}
} else {
// 상품 없이 더미 주문 옵션 생성
for ($i = 0; $i < $count; $i++) {
$optionStatus = $this->getOrderOptionStatusByOrderStatus($orderStatus);
$orderOption = OrderOptionFactory::new()
->forOrder($order)
->create([
'option_status' => $optionStatus,
'is_stock_deducted' => $this->isStockDeductedForStatus($optionStatus),
'product_id' => null,
'product_option_id' => null,
]);
$orderOptions->push($orderOption);
}
}
return $orderOptions;
}
/**
* 주문 상태에 따른 주문 옵션 상태 반환
*
* OrderStatusEnum으로 통일되었으므로 동일한 값을 반환합니다.
*/
private function getOrderOptionStatusByOrderStatus(OrderStatusEnum $orderStatus): OrderStatusEnum
{
return $orderStatus;
}
/**
* 옵션 상태에 따른 재고 차감 여부를 판정합니다.
*
* 실제 주문 프로세스(StockService::deductStock)는 결제완료/주문접수 타이밍에 재고를 차감하고,
* 취소 시 복원(is_stock_deducted=false)합니다. 시더는 이 라이프사이클을 거치지 않고 상태만
* 직접 세팅하므로, 결제완료 이후 상태(취소 제외)는 차감된 것으로 보정합니다.
*
* @param OrderStatusEnum $optionStatus 옵션 상태
* @return bool 재고 차감 여부
*/
private function isStockDeductedForStatus(OrderStatusEnum $optionStatus): bool
{
// 결제 전(주문대기/결제대기)은 미차감, 취소는 차감 후 복원되어 미차감
if ($optionStatus->isBeforePayment() || $optionStatus === OrderStatusEnum::CANCELLED) {
return false;
}
return true;
}
/**
* 주문 금액 재계산
*
* @param Collection $orderOptions
*/
private function recalculateOrderAmount(Order $order, $orderOptions): void
{
// 옵션 모델을 DB에서 다시 조회 (쿠폰 적용 후 업데이트된 값 반영)
$freshOptions = OrderOption::where('order_id', $order->id)->get();
$subtotalAmount = $freshOptions->sum('subtotal_price');
$discountAmount = $freshOptions->sum('subtotal_discount_amount');
$productCouponDiscount = $freshOptions->sum('product_coupon_discount_amount');
$orderCouponDiscount = $freshOptions->sum('order_coupon_discount_amount');
$couponDiscount = $freshOptions->sum('coupon_discount_amount');
$codeDiscount = $freshOptions->sum('code_discount_amount');
// 배송 레코드에서 실제 배송비 합산 (배송 미생성 시 0)
$shippings = OrderShipping::where('order_id', $order->id)->get();
$baseShippingAmount = (float) $shippings->sum('base_shipping_amount');
$shippingDiscountAmount = (float) $shippings->sum('shipping_discount_amount');
$shippingAmount = (float) $shippings->sum('total_shipping_amount');
$totalAmount = $subtotalAmount - $discountAmount + $shippingAmount;
$taxAmount = round($totalAmount / 11, 2);
$updateData = [
'subtotal_amount' => $subtotalAmount,
'total_discount_amount' => $discountAmount,
'total_coupon_discount_amount' => $couponDiscount,
'total_product_coupon_discount_amount' => $productCouponDiscount,
'total_order_coupon_discount_amount' => $orderCouponDiscount,
'total_code_discount_amount' => $codeDiscount,
'base_shipping_amount' => $baseShippingAmount,
'shipping_discount_amount' => $shippingDiscountAmount,
'total_shipping_amount' => $shippingAmount,
'total_amount' => $totalAmount,
'total_tax_amount' => $taxAmount,
'total_earned_points_amount' => round($totalAmount * 0.01, 2),
'item_count' => $freshOptions->count(),
'total_weight' => $freshOptions->sum('subtotal_weight'),
'total_volume' => $freshOptions->sum('subtotal_volume'),
// 다중 통화 필드 (OrderProcessingService::buildOrderMultiCurrency와 동일 형식)
'mc_subtotal_amount' => $this->buildMultiCurrencyAmount($subtotalAmount),
'mc_total_discount_amount' => $this->buildMultiCurrencyAmount($discountAmount),
'mc_total_product_coupon_discount_amount' => $this->buildMultiCurrencyAmount($productCouponDiscount),
'mc_total_order_coupon_discount_amount' => $this->buildMultiCurrencyAmount($orderCouponDiscount),
'mc_total_coupon_discount_amount' => $this->buildMultiCurrencyAmount($couponDiscount),
'mc_total_code_discount_amount' => $this->buildMultiCurrencyAmount($codeDiscount),
'mc_base_shipping_amount' => $this->buildMultiCurrencyAmount($baseShippingAmount),
'mc_extra_shipping_amount' => $this->buildMultiCurrencyAmount(0),
'mc_shipping_discount_amount' => $this->buildMultiCurrencyAmount($shippingDiscountAmount),
'mc_total_shipping_amount' => $this->buildMultiCurrencyAmount($shippingAmount),
'mc_total_points_used_amount' => $this->buildMultiCurrencyAmount(0),
'mc_total_deposit_used_amount' => $this->buildMultiCurrencyAmount(0),
'mc_total_tax_amount' => $this->buildMultiCurrencyAmount($taxAmount),
'mc_total_tax_free_amount' => $this->buildMultiCurrencyAmount(0),
'mc_total_amount' => $this->buildMultiCurrencyAmount($totalAmount),
'mc_total_paid_amount' => $this->buildMultiCurrencyAmount(0),
];
// 결제 완료 상태면 결제 금액도 업데이트
if (! $order->order_status->isBeforePayment()) {
$updateData['total_paid_amount'] = $totalAmount;
$updateData['mc_total_paid_amount'] = $this->buildMultiCurrencyAmount($totalAmount);
$updateData['total_due_amount'] = 0;
} else {
$updateData['total_due_amount'] = $totalAmount;
}
// 취소 상태면 취소 금액 업데이트
if ($order->order_status === OrderStatusEnum::CANCELLED) {
$updateData['total_cancelled_amount'] = $totalAmount;
}
$order->update($updateData);
}
/**
* 주문 주소 생성
*/
private function createOrderAddress(Order $order): void
{
OrderAddressFactory::new()
->forOrder($order)
->shipping()
->create();
}
/**
* 주문 결제 정보 생성
*/
private function createOrderPayment(Order $order, OrderStatusEnum $status): void
{
$factory = OrderPaymentFactory::new()->forOrder($order);
// 결제 방법 랜덤 선택 (카드 60%, 무통장입금 25%, 가상계좌 15%)
$paymentMethod = $this->getRandomPaymentMethod();
$factory = match ($paymentMethod) {
'card' => $factory->card(),
'dbank' => $factory->directBank(),
'vbank' => $factory->virtualAccount(),
default => $factory->card(),
};
// 상태에 따른 결제 상태
if ($status === OrderStatusEnum::CANCELLED) {
$factory = $factory->cancelled();
} elseif (in_array($paymentMethod, ['vbank', 'dbank']) && $status === OrderStatusEnum::PENDING_PAYMENT) {
$factory = $factory->pending();
} else {
$factory = $factory->completed();
}
$factory->create([
'paid_amount_local' => $order->total_amount,
'paid_amount_base' => $order->total_amount,
'vat_amount' => $order->total_tax_amount,
]);
}
/**
* 랜덤 결제 방법 반환
*/
private function getRandomPaymentMethod(): string
{
$rand = rand(1, 100);
if ($rand <= 60) {
return 'card';
} elseif ($rand <= 85) {
return 'dbank';
}
return 'vbank';
}
/**
* CurrencyConversionService 인스턴스 캐시
*/
private ?CurrencyConversionService $currencyConversionService = null;
/**
* CurrencyConversionService를 지연 로딩합니다.
*/
private function getCurrencyConversionService(): CurrencyConversionService
{
if ($this->currencyConversionService === null) {
$this->currencyConversionService = app(CurrencyConversionService::class);
}
return $this->currencyConversionService;
}
/**
* 다중 통화 금액 생성
*
* OrderProcessingService::buildAllCurrencyConverter와 동일한 형식으로
* CurrencyConversionService를 사용하여 등록된 모든 통화로 변환합니다.
*
* @param float $amount KRW 기준 금액
* @return array 다중 통화 데이터 {통화코드: 금액}
*/
private function buildMultiCurrencyAmount(float $amount): array
{
$multiCurrency = $this->getCurrencyConversionService()->convertToMultiCurrency((int) $amount);
$result = [];
foreach ($multiCurrency as $code => $data) {
$result[$code] = $data['price'];
}
// 변환 결과가 없으면 기본 통화만 반환
if (empty($result)) {
$baseCurrency = $this->getCurrencyConversionService()->getDefaultCurrency();
$result[$baseCurrency] = $amount;
}
return $result;
}
/**
* 주문 옵션의 추가옵션 스냅샷과 단위 합계를 생성합니다.
*
* 상품에 활성 추가옵션 선택지가 있으면 30% 확률로 그룹당 1개를 선택해 주문 시점 스냅샷으로
* 동결합니다. 스냅샷 형식은 ProductAdditionalOptionValue::toSnapshotArray 와 정합합니다.
*
* @param Product $product 상품 모델
* @return array{0: array<int, array>, 1: int} [스냅샷 배열, 단위당 추가금 합계(KRW)]
*/
private function buildOrderOptionAdditionalOptions(Product $product): array
{
// 활성 추가옵션 그룹·선택지 로드
$product->loadMissing('additionalOptions.activeValues');
if ($product->additionalOptions->isEmpty()) {
return [[], 0];
}
// 70% 는 추가옵션 미선택 (분포 다양화)
if (rand(1, 100) > 30) {
return [[], 0];
}
$snapshot = [];
$total = 0;
foreach ($product->additionalOptions as $group) {
$values = $group->activeValues;
if ($values->isEmpty()) {
continue;
}
// 그룹당 1개 선택 (필수 그룹은 항상, 비필수는 60% 확률)
if (! $group->is_required && rand(1, 100) > 60) {
continue;
}
/** @var ProductAdditionalOptionValue $value */
$value = $values->random();
$entry = $value->toSnapshotArray();
$entry['group_name'] = $group->name;
$entry['is_required'] = (bool) $group->is_required;
// 직접입력 선택지면 custom_text 더미 채움
if ($value->allow_custom_text) {
$entry['custom_text'] = '주문자 직접 입력 문구';
}
$snapshot[] = $entry;
$total += $value->getPriceAdjustment();
}
return [$snapshot, $total];
}
/**
* 통화 스냅샷 생성
*
* OrderProcessingService::buildCurrencySnapshot과 동일한 형식으로
* 주문 생성 시점의 통화 정보를 반환합니다.
*
* @return array 통화 스냅샷
*/
private function buildCurrencySnapshot(): array
{
$service = $this->getCurrencyConversionService();
$baseCurrency = $service->getDefaultCurrency();
$currencies = $service->getCurrencySettings();
$exchangeRates = [];
foreach ($currencies as $currency) {
$code = $currency['code'];
$rate = ($currency['is_default'] ?? false) ? 1.0 : ($currency['exchange_rate'] ?? 0);
$exchangeRates[$code] = $rate;
}
return [
'base_currency' => $baseCurrency,
'order_currency' => $baseCurrency,
'exchange_rate' => 1.0,
'exchange_rates' => $exchangeRates,
'snapshot_at' => now()->toIso8601String(),
];
}
/**
* 옵션값 요약 문자열 생성 (다국어)
*
* ProductOption의 option_values를 로케일별 요약 문자열로 변환합니다.
* 예: {"ko": "색상: 빨강, 사이즈: L", "en": "Color: Red, Size: L"}
*
* @param array|null $optionValues ProductOption의 option_values
* @return array 다국어 요약 문자열 배열
*/
private function buildMultilingualOptionValue(?array $optionValues): array
{
if (empty($optionValues)) {
return [];
}
// 새 구조: [{"key": {"ko": "색상"}, "value": {"ko": "빨강"}}]
if (isset($optionValues[0]['key'])) {
$locales = config('app.supported_locales', ['ko', 'en']);
$result = [];
foreach ($locales as $locale) {
$parts = [];
foreach ($optionValues as $item) {
$key = $item['key'] ?? [];
$value = $item['value'] ?? [];
$localizedKey = is_array($key) ? ($key[$locale] ?? $key['ko'] ?? array_values($key)[0] ?? '') : $key;
$localizedValue = is_array($value) ? ($value[$locale] ?? $value['ko'] ?? array_values($value)[0] ?? '') : $value;
if ($localizedKey !== '' && $localizedValue !== '') {
$parts[] = $localizedKey.': '.$localizedValue;
}
}
$result[$locale] = implode(', ', $parts);
}
return $result;
}
// 기존 구조: {"색상": "빨강"} (하위 호환성) - ko로만 반환
$parts = [];
foreach ($optionValues as $key => $value) {
$parts[] = $key.': '.$value;
}
return ['ko' => implode(', ', $parts)];
}
/**
* 주문 배송 정보 생성
*
* @param Collection $orderOptions
*/
private function createOrderShippings(Order $order, $orderOptions, OrderStatusEnum $status): void
{
$shippingPolicies = ShippingPolicy::with('countrySettings')->get();
// 해외 배송(API) 정책 제외 — 시더에서 외부 API 호출 불가
$domesticPolicies = $shippingPolicies->filter(function ($policy) {
return $policy->countrySettings->contains(fn ($cs) => $cs->country_code === 'KR' && $cs->is_active);
});
$packageNumber = 'PKG-'.str_pad($order->id, 8, '0', STR_PAD_LEFT);
$currencySnapshot = $order->currency_snapshot;
foreach ($orderOptions as $orderOption) {
$factory = OrderShippingFactory::new()
->forOrder($order)
->forOrderOption($orderOption);
// 상태에 따른 배송 상태
$factory = match ($status) {
OrderStatusEnum::PREPARING, OrderStatusEnum::SHIPPING_HOLD => $factory->preparing(),
OrderStatusEnum::SHIPPING_READY, OrderStatusEnum::SHIPPING => $factory->inTransit(),
OrderStatusEnum::DELIVERED, OrderStatusEnum::CONFIRMED => $factory->delivered(),
default => $factory->preparing(),
};
// 배송정책 기반 배송비 계산
$policy = $domesticPolicies->isNotEmpty() ? $domesticPolicies->random() : null;
$countrySetting = $policy?->countrySettings
->where('country_code', 'KR')
->where('is_active', true)
->first();
$shippingAmount = $countrySetting
? $this->calculateSeederShippingFee($countrySetting, $orderOption)
: 0;
$extraAttributes = [
'base_shipping_amount' => $shippingAmount,
'total_shipping_amount' => $shippingAmount,
// 필수 보완: 다중 통화
'currency_snapshot' => $currencySnapshot,
'mc_base_shipping_amount' => $this->buildMultiCurrencyAmount($shippingAmount),
'mc_total_shipping_amount' => $this->buildMultiCurrencyAmount($shippingAmount),
'mc_return_shipping_amount' => $this->buildMultiCurrencyAmount(0),
// 선택적 보완: 묶음배송 번호
'package_number' => $packageNumber,
];
// 배송정책 연결 + 배송정책 스냅샷
if ($policy) {
$extraAttributes['shipping_policy_id'] = $policy->id;
$extraAttributes['delivery_policy_snapshot'] = $this->buildDeliveryPolicySnapshot($policy, $shippingAmount);
}
// 논리적 보완: 구매확정 상태 → confirmed_at 설정
if ($status === OrderStatusEnum::CONFIRMED) {
$extraAttributes['confirmed_at'] = now()->subDays(rand(1, 3));
}
$factory->create($extraAttributes);
}
}
/**
* 시더용 배송비 계산 (실제 OrderCalculationService 로직 간소화)
*
* charge_policy 기반으로 배송비를 산출합니다.
* 실제 주문 시 calculateCountryShippingFee()와 동일한 분기 로직을 적용합니다.
*
* @param ShippingPolicyCountrySetting $countrySetting
* @return int 배송비 (원)
*/
private function calculateSeederShippingFee($countrySetting, OrderOption $orderOption): int
{
$subtotal = (int) $orderOption->subtotal_price;
$quantity = (int) $orderOption->quantity;
// 주문 옵션의 무게/부피는 g / cm³ 로 저장된다 (미입력 시 1개당 500g / 1,000cm³ 로 가정)
$weightGram = (float) ($orderOption->subtotal_weight ?? $quantity * 500);
$volumeCubicCm = (float) ($orderOption->subtotal_volume ?? $quantity * 1000);
$baseFee = (int) round((float) $countrySetting->base_fee);
$ranges = $countrySetting->ranges;
// 배송정책의 구간·단위값은 kg / L 이므로 여기서 환산한다 (엔진과 동일 규칙)
$volumeWeightDivisor = $ranges['volume_weight_divisor'] ?? 6000;
$weightKg = $weightGram / 1000;
$volumeLiter = $volumeCubicCm / 1000;
$volumetricWeightKg = $volumeWeightDivisor > 0 ? $volumeCubicCm / $volumeWeightDivisor : 0.0;
$chargeableWeightKg = max($weightKg, $volumetricWeightKg);
return match ($countrySetting->charge_policy) {
ChargePolicyEnum::FREE => 0,
ChargePolicyEnum::FIXED => $baseFee,
ChargePolicyEnum::CONDITIONAL_FREE => $subtotal >= (int) $countrySetting->free_threshold ? 0 : $baseFee,
ChargePolicyEnum::RANGE_AMOUNT => $this->calculateSeederRangeFee($ranges, $subtotal),
ChargePolicyEnum::RANGE_QUANTITY => $this->calculateSeederRangeFee($ranges, $quantity),
ChargePolicyEnum::RANGE_WEIGHT => $this->calculateSeederRangeFee($ranges, $weightKg),
ChargePolicyEnum::RANGE_VOLUME => $this->calculateSeederRangeFee($ranges, $volumeLiter),
ChargePolicyEnum::RANGE_VOLUME_WEIGHT => $this->calculateSeederRangeFee($ranges, $chargeableWeightKg),
ChargePolicyEnum::PER_QUANTITY => (int) round(ceil($quantity / max($ranges['unit_value'] ?? 1, 1)) * $baseFee),
ChargePolicyEnum::PER_WEIGHT => (int) round(ceil($weightKg / max($ranges['unit_value'] ?? 0.5, 0.01)) * $baseFee),
ChargePolicyEnum::PER_VOLUME => (int) round(ceil($volumeLiter / max($ranges['unit_value'] ?? 1, 0.01)) * $baseFee),
ChargePolicyEnum::PER_VOLUME_WEIGHT => (int) round(ceil($chargeableWeightKg / max($ranges['unit_value'] ?? 0.5, 0.01)) * $baseFee),
ChargePolicyEnum::PER_AMOUNT => (int) round(ceil($subtotal / max($ranges['unit_value'] ?? 10000, 1)) * $baseFee),
ChargePolicyEnum::API => $baseFee, // API는 시더에서 base_fee 사용
default => $baseFee,
};
}
/**
* 구간별 배송비 계산 (시더용 — 상한 포함 사다리 매칭)
*
* 구간을 상한 오름차순(무제한이 마지막)으로 정렬한 뒤, 값이 상한 이하인 첫 구간의
* 배송비를 반환합니다. 시작값은 표시용이라 매칭에 쓰지 않습니다
* (OrderCalculationService::calculateRangeFee 와 동일 규칙).
*
* @param array|null $ranges 구간 설정
* @param int|float $value 비교 값 (금액·수량·kg·L)
* @return int 배송비
*/
private function calculateSeederRangeFee(?array $ranges, int|float $value): int
{
$tiers = is_array($ranges) ? ($ranges['tiers'] ?? []) : [];
if (! is_array($tiers) || count($tiers) === 0) {
return 0;
}
$sortedTiers = array_values($tiers);
usort($sortedTiers, function ($a, $b) {
$aMax = ($a['max'] ?? null) === null || ($a['max'] ?? null) === '' ? null : (float) $a['max'];
$bMax = ($b['max'] ?? null) === null || ($b['max'] ?? null) === '' ? null : (float) $b['max'];
if ($aMax === null && $bMax === null) {
return 0;
}
return $aMax === null ? 1 : ($bMax === null ? -1 : $aMax <=> $bMax);
});
foreach ($sortedTiers as $tier) {
$max = ($tier['max'] ?? null) === null || ($tier['max'] ?? null) === '' ? null : (float) $tier['max'];
if ($max === null || (float) $value <= $max) {
return (int) round((float) ($tier['fee'] ?? 0));
}
}
return 0;
}
/**
* 사용자별 사용 가능한 쿠폰 발급 목록을 로드합니다.
*
* CouponSeeder에서 생성한 available 상태의 발급 내역을
* 쿠폰 정보와 함께 사용자별로 그룹화하여 반환합니다.
*
* @return array<int, Collection> [user_id => Collection<CouponIssue>]
*/
private function loadAvailableCouponIssuesByUser(): array
{
$issues = CouponIssue::with('coupon')
->where('status', CouponIssueRecordStatus::AVAILABLE)
->whereHas('coupon', fn ($q) => $q->whereNull('deleted_at'))
->get();
if ($issues->isEmpty()) {
$this->command->line(' - 사용 가능한 쿠폰 발급 내역이 없습니다. 쿠폰 미적용 주문만 생성합니다.');
return [];
}
// Collection 그대로 유지 (모델 인스턴스 보존)
$grouped = $issues->groupBy('user_id')->all();
$this->command->line(" - 쿠폰 발급 내역 {$issues->count()}건 로드 (".count($grouped).'명 사용자)');
return $grouped;
}
/**
* 주문 옵션에 쿠폰 할인을 적용합니다.
*
* 사용자의 사용 가능한 쿠폰 중 1~2개를 선택하여 적용합니다.
* - 상품금액 쿠폰 (product_amount): 각 옵션에 균등 분배
* - 주문금액 쿠폰 (order_amount): 각 옵션에 금액 비율로 배분
* - 배송비 쿠폰 (shipping_fee): 배송 레코드 생성 후 적용하기 위해 반환
* 적용 후 CouponIssue 상태를 'used'로 업데이트합니다.
*
* @param Order $order 주문
* @param Collection $orderOptions 주문 옵션 목록
* @param Collection $userCouponIssues 사용자의 쿠폰 발급 목록
* @return array 배송비 쿠폰 목록 (배송 레코드 생성 후 적용 필요)
*/
private function applyCouponsToOrderOptions(Order $order, $orderOptions, $userCouponIssues): array
{
// Collection으로 변환 (array로 전달된 경우)
$issues = collect($userCouponIssues);
// 사용 가능한 쿠폰 필터링 (available 상태, 만료되지 않은 것)
$available = $issues->filter(function ($issue) {
if (! ($issue instanceof CouponIssue)) {
return false;
}
return $issue->status === CouponIssueRecordStatus::AVAILABLE
&& $issue->coupon
&& ($issue->expired_at === null || $issue->expired_at->isFuture());
});
if ($available->isEmpty()) {
return [];
}
$orderSubtotal = $orderOptions->sum('subtotal_price');
// 1~2개 쿠폰 랜덤 선택 (중복불가 쿠폰은 1개만)
$selected = $this->selectCouponsForOrder($available, $orderSubtotal, $orderOptions);
if (empty($selected)) {
return [];
}
// 배송비 쿠폰과 상품/주문 쿠폰 분리
$shippingCoupons = [];
$optionCoupons = [];
foreach ($selected as $issueData) {
if ($issueData['coupon']->target_type === CouponTargetType::SHIPPING_FEE) {
$shippingCoupons[] = $issueData;
} else {
$optionCoupons[] = $issueData;
}
}
// 쿠폰별 할인금액 계산 및 옵션에 배분
$promotionSnapshot = ['coupons' => [], 'discount_codes' => [], 'events' => []];
foreach ($optionCoupons as $issueData) {
/** @var CouponIssue $issueModel */
$issueModel = $issueData['issue'];
$coupon = $issueData['coupon'];
$discountAmount = $issueData['discount'];
// 옵션별 할인 배분
$this->distributeDiscountToOptions($orderOptions, $coupon, $discountAmount, $orderSubtotal);
// CouponIssue 상태 업데이트 (메모리 상 모델도 갱신하여 중복 선택 방지)
$issueModel->status = CouponIssueRecordStatus::USED;
$issueModel->used_at = $order->ordered_at ?? now();
$issueModel->order_id = $order->id;
$issueModel->discount_amount = $discountAmount;
$issueModel->save();
// 소진 기록 추가 (보충용)
$this->usedCouponRecords[] = ['user_id' => $issueModel->user_id, 'coupon_id' => $coupon->id];
// 프로모션 스냅샷 빌드
$promotionSnapshot['coupons'][] = $this->buildCouponSnapshotEntry($coupon, $issueModel, $discountAmount, $orderOptions);
}
// 배송비 쿠폰도 CouponIssue 사용 처리 + 스냅샷 추가
foreach ($shippingCoupons as $issueData) {
/** @var CouponIssue $issueModel */
$issueModel = $issueData['issue'];
$coupon = $issueData['coupon'];
$discountAmount = $issueData['discount'];
$issueModel->status = CouponIssueRecordStatus::USED;
$issueModel->used_at = $order->ordered_at ?? now();
$issueModel->order_id = $order->id;
$issueModel->discount_amount = $discountAmount;
$issueModel->save();
// 소진 기록 추가 (보충용)
$this->usedCouponRecords[] = ['user_id' => $issueModel->user_id, 'coupon_id' => $coupon->id];
$promotionSnapshot['coupons'][] = $this->buildCouponSnapshotEntry($coupon, $issueModel, $discountAmount, $orderOptions);
}
// 모든 옵션에 promotions_applied_snapshot 업데이트
if (! empty($promotionSnapshot['coupons'])) {
foreach ($orderOptions as $opt) {
$opt->update(['promotions_applied_snapshot' => $promotionSnapshot]);
}
}
// 주문 레벨 promotions_applied_snapshot 저장
// OrderAdjustmentService.buildRecalcInput()이 쿠폰 재적용에 필요
$allSelected = array_merge($optionCoupons, $shippingCoupons);
if (! empty($allSelected)) {
$orderSnapshot = $this->buildOrderPromotionsSnapshot($allSelected, $orderOptions);
$order->update(['promotions_applied_snapshot' => $orderSnapshot]);
}
return $shippingCoupons;
}
/**
* 쿠폰 스냅샷 항목을 생성합니다.
*
* @param Coupon $coupon 쿠폰 모델
* @param CouponIssue $issueModel 쿠폰 발급 모델
* @param int $discountAmount 할인금액
* @param Collection $orderOptions 주문 옵션 목록
*/
private function buildCouponSnapshotEntry(Coupon $coupon, CouponIssue $issueModel, int $discountAmount, $orderOptions): array
{
$couponName = is_array($coupon->name)
? ($coupon->name['ko'] ?? $coupon->name['en'] ?? '')
: (string) $coupon->name;
return [
'coupon_id' => $coupon->id,
'coupon_issue_id' => $issueModel->id,
'name' => $couponName,
'target_type' => $coupon->target_type->value,
'target_scope' => $coupon->target_scope,
'discount_type' => $coupon->discount_type->value,
'discount_value' => (float) $coupon->discount_value,
'total_discount' => $discountAmount,
'total_discount_formatted' => number_format($discountAmount).'원',
'applied_items' => $orderOptions->map(fn ($opt) => [
'product_option_id' => $opt->product_option_id,
'discount_amount' => (int) $opt->product_coupon_discount_amount + (int) $opt->order_coupon_discount_amount,
'discount_amount_formatted' => number_format((int) $opt->product_coupon_discount_amount + (int) $opt->order_coupon_discount_amount).'원',
])->toArray(),
'is_exclusive' => ! $coupon->is_combinable,
'min_order_amount' => (int) $coupon->min_order_amount,
'max_discount_amount' => (int) ($coupon->discount_max_amount ?? 0),
];
}
/**
* 주문에 적용할 쿠폰을 선택합니다.
*
* 최소 주문금액 조건을 만족하는 쿠폰 중 1~2개를 선택합니다.
* 중복불가(is_combinable=false) 쿠폰은 단독 적용됩니다.
*
* @param Collection $available 사용 가능한 쿠폰 발급 목록
* @param float $orderSubtotal 주문 소계
* @return array [{issue, coupon, discount}]
*/
private function selectCouponsForOrder($available, float $orderSubtotal, $orderOptions = null): array
{
$candidates = [];
foreach ($available as $issueModel) {
if (! ($issueModel instanceof CouponIssue) || ! $issueModel->coupon) {
continue;
}
$coupon = $issueModel->coupon;
// 최소 주문금액 조건
if ($orderSubtotal < (float) $coupon->min_order_amount) {
continue;
}
// 상품금액 정액 쿠폰: 적용 대상 옵션의 총 수량만큼 할인
$totalQuantity = 1;
if ($orderOptions
&& $coupon->target_type === CouponTargetType::PRODUCT_AMOUNT
&& $coupon->discount_type === CouponDiscountType::FIXED
) {
$totalQuantity = $this->getTargetQuantity($coupon, $orderOptions);
}
$discount = $this->calculateCouponDiscount($coupon, $orderSubtotal, $totalQuantity);
if ($discount <= 0) {
continue;
}
$candidates[] = [
'issue' => $issueModel,
'coupon' => $coupon,
'discount' => $discount,
];
}
if (empty($candidates)) {
return [];
}
// 중복불가 쿠폰이 있으면 단독 적용
$exclusives = array_values(array_filter($candidates, fn ($c) => ! $c['coupon']->is_combinable));
if (! empty($exclusives) && rand(1, 100) <= 30) {
return [$exclusives[array_rand($exclusives)]];
}
// 중복 가능 쿠폰 중 1~2개 선택
$combinables = array_values(array_filter($candidates, fn ($c) => $c['coupon']->is_combinable));
if (empty($combinables)) {
// 중복 가능 쿠폰이 없으면 중복불가 쿠폰 1개
return [$exclusives[array_rand($exclusives)]];
}
$combinables = array_values($combinables);
shuffle($combinables);
$pickCount = min(rand(1, 2), count($combinables));
// 같은 target_type 쿠폰 중복 방지
$picked = [];
$usedTypes = [];
foreach ($combinables as $c) {
if (count($picked) >= $pickCount) {
break;
}
if (in_array($c['coupon']->target_type, $usedTypes)) {
continue;
}
$picked[] = $c;
$usedTypes[] = $c['coupon']->target_type;
}
return $picked;
}
/**
* 쿠폰의 할인금액을 계산합니다.
*
* @param Coupon $coupon 쿠폰 모델
* @param float $orderSubtotal 주문 소계
* @return int 할인금액
*/
private function calculateCouponDiscount(Coupon $coupon, float $orderSubtotal, int $quantity = 1): int
{
if ($coupon->discount_type === CouponDiscountType::FIXED) {
// 정액 할인: 수량만큼 할인 (상품금액 쿠폰에서 수량 전달)
$discount = (int) $coupon->discount_value * $quantity;
} else {
// 정률 할인
$discount = (int) floor($orderSubtotal * (float) $coupon->discount_value / 100);
// 최대 할인금액 제한
if ($coupon->discount_max_amount && $discount > (int) $coupon->discount_max_amount) {
$discount = (int) $coupon->discount_max_amount;
}
}
// 할인금액이 주문금액을 초과하지 않도록
return min($discount, (int) $orderSubtotal);
}
/**
* 할인금액을 주문 옵션에 배분합니다.
*
* - 상품금액 쿠폰: product_coupon_discount_amount에 배분
* - 주문금액 쿠폰: order_coupon_discount_amount에 금액 비율로 배분
*
* @param Collection $orderOptions 주문 옵션 목록
* @param Coupon $coupon 쿠폰 모델
* @param int $totalDiscount 총 할인금액
* @param float $orderSubtotal 주문 소계
*/
private function distributeDiscountToOptions($orderOptions, Coupon $coupon, int $totalDiscount, float $orderSubtotal): void
{
$isProductCoupon = $coupon->target_type === CouponTargetType::PRODUCT_AMOUNT;
$discountField = $isProductCoupon ? 'product_coupon_discount_amount' : 'order_coupon_discount_amount';
$remaining = $totalDiscount;
$optionCount = $orderOptions->count();
foreach ($orderOptions as $index => $opt) {
// 마지막 옵션에 나머지 배분 (반올림 오차 방지)
if ($index === $optionCount - 1) {
$share = $remaining;
} else {
// 금액 비율로 배분
$ratio = $orderSubtotal > 0 ? (float) $opt->subtotal_price / $orderSubtotal : (1 / $optionCount);
$share = (int) floor($totalDiscount * $ratio);
}
$remaining -= $share;
$currentFieldValue = (int) $opt->$discountField;
$currentCouponTotal = (int) $opt->coupon_discount_amount;
$newFieldValue = $currentFieldValue + $share;
$newCouponTotal = $currentCouponTotal + $share;
$newSubtotalDiscount = (int) $opt->subtotal_discount_amount + $share;
$newFinalAmount = (int) $opt->subtotal_price - $newSubtotalDiscount;
$opt->update([
$discountField => $newFieldValue,
'coupon_discount_amount' => $newCouponTotal,
'subtotal_discount_amount' => $newSubtotalDiscount,
'subtotal_paid_amount' => $newFinalAmount,
'subtotal_tax_amount' => round($newFinalAmount / 11, 2),
'subtotal_earned_points_amount' => round($newFinalAmount * 0.01, 2),
// 다중 통화
'mc_'.($isProductCoupon ? 'product_coupon' : 'order_coupon').'_discount_amount' => $this->buildMultiCurrencyAmount($newFieldValue),
'mc_coupon_discount_amount' => $this->buildMultiCurrencyAmount($newCouponTotal),
'mc_subtotal_tax_amount' => $this->buildMultiCurrencyAmount(round($newFinalAmount / 11, 2)),
'mc_final_amount' => $this->buildMultiCurrencyAmount($newFinalAmount),
]);
}
}
/**
* 쿠폰 적용 대상 옵션의 총 수량을 계산합니다.
*
* target_scope에 따라:
* - ALL: 모든 옵션의 수량 합계
* - PRODUCTS: 쿠폰에 포함된 상품의 옵션 수량 합계
* - CATEGORIES: 쿠폰에 포함된 카테고리에 속한 상품의 옵션 수량 합계
*
* @param Coupon $coupon 쿠폰 모델
* @param Collection $orderOptions 주문 옵션 목록
* @return int 적용 대상 총 수량
*/
private function getTargetQuantity(Coupon $coupon, $orderOptions): int
{
if ($coupon->target_scope === CouponTargetScope::ALL || $coupon->target_scope === null) {
return $orderOptions->sum('quantity');
}
if ($coupon->target_scope === CouponTargetScope::PRODUCTS) {
$includedProductIds = $coupon->includedProducts()->pluck('product_id')->toArray();
if (empty($includedProductIds)) {
return $orderOptions->sum('quantity');
}
return $orderOptions
->filter(fn ($opt) => in_array($opt->product_id, $includedProductIds))
->sum('quantity');
}
if ($coupon->target_scope === CouponTargetScope::CATEGORIES) {
$includedCategoryIds = $coupon->includedCategories()->pluck('category_id')->toArray();
if (empty($includedCategoryIds)) {
return $orderOptions->sum('quantity');
}
return $orderOptions
->filter(function ($opt) use ($includedCategoryIds) {
$product = Product::find($opt->product_id);
if (! $product) {
return false;
}
$productCategoryIds = $product->categories()->pluck('id')->toArray();
return ! empty(array_intersect($productCategoryIds, $includedCategoryIds));
})
->sum('quantity');
}
return $orderOptions->sum('quantity');
}
/**
* 주문 레벨 promotions_applied_snapshot을 생성합니다.
*
* OrderAdjustmentService.buildRecalcInput()이 부분취소 시 쿠폰을 재적용하기 위해
* coupon_issue_ids, product_promotions, order_promotions 구조가 필요합니다.
*
* @param array $selectedCoupons 적용된 쿠폰 목록 [{issue, coupon, discount}]
* @param Collection $orderOptions 주문 옵션 목록
* @return array 주문 레벨 스냅샷
*/
private function buildOrderPromotionsSnapshot(array $selectedCoupons, $orderOptions): array
{
$couponIssueIds = [];
$productPromotionCoupons = [];
$orderPromotionCoupons = [];
foreach ($selectedCoupons as $issueData) {
/** @var CouponIssue $issueModel */
$issueModel = $issueData['issue'];
$coupon = $issueData['coupon'];
$discountAmount = $issueData['discount'];
$couponIssueIds[] = $issueModel->id;
$couponName = is_array($coupon->name)
? ($coupon->name['ko'] ?? $coupon->name['en'] ?? '')
: (string) $coupon->name;
$entry = [
'coupon_issue_id' => $issueModel->id,
'coupon_id' => $coupon->id,
'name' => $couponName,
'discount_type' => $coupon->discount_type->value,
'discount_value' => (float) $coupon->discount_value,
'min_order_amount' => (int) $coupon->min_order_amount,
'max_discount_amount' => (int) ($coupon->discount_max_amount ?? 0),
'target_type' => $coupon->target_type->value,
'target_scope' => $coupon->target_scope,
'is_exclusive' => ! $coupon->is_combinable,
'total_discount' => $discountAmount,
'applied_items' => $orderOptions->map(fn ($opt) => [
'product_option_id' => $opt->product_option_id,
'discount_amount' => (int) $opt->product_coupon_discount_amount + (int) $opt->order_coupon_discount_amount,
])->toArray(),
];
// target_type에 따라 product_promotions 또는 order_promotions에 분류
if ($coupon->target_type === CouponTargetType::PRODUCT_AMOUNT) {
$productPromotionCoupons[] = $entry;
} else {
// order_amount, shipping_fee → order_promotions
$orderPromotionCoupons[] = $entry;
}
}
return [
'coupon_issue_ids' => $couponIssueIds,
'item_coupons' => [],
'discount_code' => null,
'product_promotions' => [
'coupons' => $productPromotionCoupons,
],
'order_promotions' => [
'coupons' => $orderPromotionCoupons,
],
];
}
/**
* 배송비 쿠폰을 배송 레코드에 적용합니다.
*
* 배송비 쿠폰의 할인금액을 배송 레코드의 shipping_discount_amount에 배분하고,
* total_shipping_amount를 재계산합니다.
*
* @param Order $order 주문
* @param array $shippingCoupons 배송비 쿠폰 목록 [{issue, coupon, discount}]
*/
private function applyShippingCouponsToShippings(Order $order, array $shippingCoupons): void
{
$shippings = OrderShipping::where('order_id', $order->id)->get();
if ($shippings->isEmpty()) {
return;
}
foreach ($shippingCoupons as $issueData) {
$coupon = $issueData['coupon'];
$totalShippingAmount = $shippings->sum('total_shipping_amount');
if ($totalShippingAmount <= 0) {
continue;
}
// 배송비 쿠폰 할인: 실제 배송비를 초과하지 않도록 제한
$discount = min($issueData['discount'], (int) $totalShippingAmount);
// 배송 레코드별 배분 (배송비 비율 기준)
$remaining = $discount;
$shippingCount = $shippings->count();
foreach ($shippings as $index => $shipping) {
if ($index === $shippingCount - 1) {
$share = $remaining;
} else {
$ratio = $totalShippingAmount > 0
? (float) $shipping->total_shipping_amount / $totalShippingAmount
: (1 / $shippingCount);
$share = (int) floor($discount * $ratio);
}
$remaining -= $share;
$currentDiscount = (int) $shipping->shipping_discount_amount;
$newDiscount = $currentDiscount + $share;
$newTotalShipping = max(0, (int) $shipping->base_shipping_amount - $newDiscount);
$shipping->update([
'shipping_discount_amount' => $newDiscount,
'total_shipping_amount' => $newTotalShipping,
'mc_shipping_discount_amount' => $this->buildMultiCurrencyAmount($newDiscount),
'mc_total_shipping_amount' => $this->buildMultiCurrencyAmount($newTotalShipping),
]);
}
}
}
/**
* 배송정책 스냅샷 생성
*
* AppliedShippingPolicy DTO의 toArray() 형식과 동일한 구조로
* 주문 시점의 배송정책 정보를 스냅샷합니다.
* 국가별 설정(countrySettings)에서 첫 번째 활성 설정을 기반으로 생성합니다.
*
* @param ShippingPolicy $policy 배송정책 모델
* @param float $shippingAmount 배송비
* @return array 배송정책 스냅샷
*/
private function buildDeliveryPolicySnapshot(ShippingPolicy $policy, float $shippingAmount): array
{
$countrySetting = $policy->countrySettings()->where('is_active', true)->first();
return [
'policy_id' => $policy->id,
'policy_name' => $policy->getLocalizedName(),
'country_code' => $countrySetting?->country_code ?? 'KR',
'charge_policy' => $countrySetting?->charge_policy?->value ?? '',
'shipping_method' => $countrySetting?->shipping_method ?? '',
'base_fee' => (float) ($countrySetting?->base_fee ?? 0),
'free_threshold' => $countrySetting?->free_threshold ? (float) $countrySetting->free_threshold : null,
'currency_code' => $countrySetting?->currency_code ?? $this->getCurrencyConversionService()->getDefaultCurrency(),
'extra_fee_enabled' => (bool) ($countrySetting?->extra_fee_enabled ?? false),
'shipping_amount' => $shippingAmount,
'snapshot_at' => now()->toIso8601String(),
];
}
/**
* 주문 생성 중 소진된 쿠폰을 보충합니다.
*
* 주문 생성 과정에서 기록된 소진 내역(usedCouponRecords)을 기반으로,
* 해당 사용자-쿠폰 조합에 available 이슈를 1건씩 재발급합니다.
*/
private function replenishAvailableCoupons(): void
{
if (empty($this->usedCouponRecords)) {
return;
}
// 중복 제거 (동일 사용자-쿠폰 조합은 1건만 보충)
$unique = collect($this->usedCouponRecords)
->unique(fn ($r) => $r['user_id'].'-'.$r['coupon_id']);
$now = now();
$replenished = 0;
$perCoupon = [];
foreach ($unique as $record) {
CouponIssue::create([
'coupon_id' => $record['coupon_id'],
'user_id' => $record['user_id'],
'coupon_code' => null,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => $now,
'expired_at' => null,
'used_at' => null,
'order_id' => null,
'discount_amount' => null,
]);
$perCoupon[$record['coupon_id']] = ($perCoupon[$record['coupon_id']] ?? 0) + 1;
$replenished++;
}
// 발급수 카운터 동기화 — 보충 발급도 발급이므로 issued_count 에 반영한다.
// 누락하면 쿠폰 목록의 "발급수" 와 발급 이력의 "총 N건" 이 영구히 어긋난다.
foreach ($perCoupon as $couponId => $count) {
Coupon::where('id', $couponId)->increment('issued_count', $count);
}
$this->command->line(" - 주문서 테스트용 쿠폰 {$replenished}건 보충 발급 완료");
}
}