diff --git a/app/Endpoint/Api/Http/Controllers/CouponController.php b/app/Endpoint/Api/Http/Controllers/CouponController.php index 9baac5c3..b72f006e 100644 --- a/app/Endpoint/Api/Http/Controllers/CouponController.php +++ b/app/Endpoint/Api/Http/Controllers/CouponController.php @@ -2,7 +2,7 @@ namespace App\Endpoint\Api\Http\Controllers; -use App\Endpoint\Api\Http\Resources\CouponResource; +use App\Endpoint\Api\Http\Resources\UserCouponResource; use App\Helpers\Paginator as PaginatorHelper; use Illuminate\Http\Request; @@ -22,6 +22,6 @@ class CouponController extends Controller ->filter($request->all()) ->simplePaginate(PaginatorHelper::resolvePerPage('per_page', 20, 50)); - return CouponResource::collection($coupons); + return UserCouponResource::collection($coupons); } } diff --git a/app/Endpoint/Api/Http/Controllers/Order/OrderVerifyController.php b/app/Endpoint/Api/Http/Controllers/Order/OrderVerifyController.php new file mode 100644 index 00000000..1ef5c5ed --- /dev/null +++ b/app/Endpoint/Api/Http/Controllers/Order/OrderVerifyController.php @@ -0,0 +1,63 @@ + ['bail', 'nullable', 'int'], + 'shipping_address_id' => ['bail', 'nullable', 'int'], + ]; + + // 快速下单 + if ($isQuick = $request->filled('product')) { + $rules = array_merge($rules, [ + 'product.sku_id' => ['bail', 'required', 'int'], + 'product.quantity' => ['bail', 'required', 'int', 'min:1'], + ]); + } else { + $rules = array_merge($rules, [ + 'shopping_cart' => ['bail', 'required', 'array'], + ]); + } + + $request->validate($rules, [], [ + 'shopping_cart' => '购物车商品', + 'product.sku_id' => '商品', + 'product.quantity' => '数量', + 'coupon_id' => '优惠券', + 'shipping_address_id' => '收货地址', + ]); + + $user = $request->user(); + + return response()->json( + $isQuick + ? $orderService->verifyQuickOrder( + $user, + $request->input('product.sku_id'), + $request->input('product.quantity'), + $request->input('shipping_address_id'), + $request->input('coupon_id'), + ) : $orderService->verifyShoppingCartOrder( + $user, + $request->input('shopping_cart'), + $request->input('shipping_address_id'), + $request->input('coupon_id'), + ) + ); + } +} diff --git a/app/Endpoint/Api/Http/Resources/ProductSku/ProductSkuSimpleResource.php b/app/Endpoint/Api/Http/Resources/ProductSku/ProductSkuSimpleResource.php index 82c0debd..252318d2 100644 --- a/app/Endpoint/Api/Http/Resources/ProductSku/ProductSkuSimpleResource.php +++ b/app/Endpoint/Api/Http/Resources/ProductSku/ProductSkuSimpleResource.php @@ -20,6 +20,9 @@ class ProductSkuSimpleResource extends JsonResource 'cover' => (string) $this->cover, 'sell_price' => $this->sell_price, 'vip_price' => (string) $this->vip_price, + 'specs' => array_values((array) $this->specs), + 'stock' => (int) $this->saleable_stock, + 'is_online' => $this->isOnline(), ]; } } diff --git a/app/Endpoint/Api/Http/Resources/CouponResource.php b/app/Endpoint/Api/Http/Resources/UserCouponResource.php similarity index 76% rename from app/Endpoint/Api/Http/Resources/CouponResource.php rename to app/Endpoint/Api/Http/Resources/UserCouponResource.php index 8ea52be6..d97320dd 100644 --- a/app/Endpoint/Api/Http/Resources/CouponResource.php +++ b/app/Endpoint/Api/Http/Resources/UserCouponResource.php @@ -4,7 +4,7 @@ namespace App\Endpoint\Api\Http\Resources; use Illuminate\Http\Resources\Json\JsonResource; -class CouponResource extends JsonResource +class UserCouponResource extends JsonResource { /** * Transform the resource into an array. @@ -20,8 +20,8 @@ class CouponResource extends JsonResource 'type' => $this->coupon_type, 'amount' => $this->coupon_amount_format, 'threshold' => $this->coupon_threshold_format, - 'use_start_at' => $this->use_start_at, - 'use_end_at' => $this->use_end_at, + 'use_start_at' => $this->use_start_at->toDateTimeString(), + 'use_end_at' => $this->use_end_at->toDateTimeString(), ]; } } diff --git a/app/Endpoint/Api/routes.php b/app/Endpoint/Api/routes.php index 652df087..c5d97fa8 100644 --- a/app/Endpoint/Api/routes.php +++ b/app/Endpoint/Api/routes.php @@ -14,6 +14,7 @@ use App\Endpoint\Api\Http\Controllers\ClickController; use App\Endpoint\Api\Http\Controllers\CouponController; use App\Endpoint\Api\Http\Controllers\MessageController; use App\Endpoint\Api\Http\Controllers\Order\OrderController; +use App\Endpoint\Api\Http\Controllers\Order\OrderVerifyController; use App\Endpoint\Api\Http\Controllers\Product\HotController; use App\Endpoint\Api\Http\Controllers\Product\ProductCategoryController; use App\Endpoint\Api\Http\Controllers\Product\ProductSkuController; @@ -107,5 +108,6 @@ Route::group([ // 订单 Route::apiResource('order/orders', OrderController::class); + Route::post('order/verify-order', OrderVerifyController::class); }); }); diff --git a/app/Exceptions/ShippingNotSupportedException.php b/app/Exceptions/ShippingNotSupportedException.php new file mode 100644 index 00000000..f3f44958 --- /dev/null +++ b/app/Exceptions/ShippingNotSupportedException.php @@ -0,0 +1,11 @@ +attributes['total_amount'], 100, 2)); + return Numeric::trimTrailingZero(bcdiv($this->attributes['total_amount'], 100, 2)); } public function packageProducts() diff --git a/app/Models/ProductSku.php b/app/Models/ProductSku.php index 26747e9d..0f2bfd8c 100644 --- a/app/Models/ProductSku.php +++ b/app/Models/ProductSku.php @@ -125,6 +125,21 @@ class ProductSku extends Model return $this->release_at !== null; } + /** + * 根据用户来获取商品的真实价格 + * + * @param \App\Models\User $user + * @return int + */ + public function getRealPrice(User $user): int + { + if (! is_null($this->vip_price) && $user->isVip()) { + return $this->vip_price; + } + + return $this->sell_price; + } + /** * 获取此商品的可售库存 * diff --git a/app/Models/User.php b/app/Models/User.php index 272a3ac9..47548898 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -117,7 +117,7 @@ class User extends Model implements AuthorizableContract, AuthenticatableContrac */ public function isVip(): bool { - return false; + return true; } /** @@ -130,8 +130,6 @@ class User extends Model implements AuthorizableContract, AuthenticatableContrac /** * 属于此用户的优惠券 - * - * @return void */ public function coupons() { diff --git a/app/Models/UserCoupon.php b/app/Models/UserCoupon.php index 1992a834..1e732779 100644 --- a/app/Models/UserCoupon.php +++ b/app/Models/UserCoupon.php @@ -24,6 +24,8 @@ class UserCoupon extends Model */ protected $casts = [ 'is_use' => 'bool', + 'use_start_at' => 'datetime', + 'use_end_at' => 'datetime', ]; /** @@ -87,6 +89,30 @@ class UserCoupon extends Model return $this->coupon_type === Coupon::TYPE_DISCOUNT; } + /** + * 确认此优惠券是否支持商品使用 + * + * @param \App\Models\ProductSku $sku + * @return bool + */ + public function isSupport(ProductSku $sku): bool + { + if ($this->ranges->count() === 0) { + return true; + } + + foreach ($this->ranges as $range) { + if ( + ($range->isTypeCategory() && in_array($sku->category_id, $range->rangeIds)) + || ($range->isTypeProduct() && in_array($sku->id, $range->rangeIds)) + ) { + return true; + } + } + + return false; + } + /** * 获取此优惠券的面值 * diff --git a/app/Services/CouponService.php b/app/Services/CouponService.php index 61099377..1ffdbc8a 100644 --- a/app/Services/CouponService.php +++ b/app/Services/CouponService.php @@ -2,10 +2,7 @@ namespace App\Services; -use App\Models\ProductSku; use App\Models\User; -use App\Models\UserCoupon; -use Illuminate\Support\Collection; class CouponService { @@ -13,115 +10,44 @@ class CouponService * 根据SKU商品获取可用优惠券 * * @param User $user - * @param array $skus 至少包含以下内容 - * [ - * ['id', 'category_id', 'sell_price', 'vip_price', 'num'] - * ] - * @return collection + * @param array $products 至少包含以下内容 + * [ + * ['sku' => 商品SKU, 'quantity' => 10] + * ] + * @return array */ - public function availableCouponsToUser(User $user, array $skus): collection + public function getAvailableCoupons(User $user, array $products): array { - //获取用户当前所有可用券 - $coupons = $this->userValidCoupons($user); + $coupons = $user->coupons()->onlyUnuse()->get(); + + $coupons->load(['ranges' => function ($query) { + $query->isEnable(); + }]); - $couponSkus = []; $availableCoupons = []; foreach ($coupons as $coupon) { - if (!isset($couponSkus[$coupon->coupon_id])) { - foreach ($skus as $sku) { - //这个商品是否在这个券的可用范围 - if ($this->isAvailableCouponToSku($sku, $coupon)) { - //按coupon_id 分组商品 - $couponSkus[$coupon->coupon_id][] = $sku; - } + // 是否满足券使用规则 + $passes = false; + + // 可用优惠券的商品的总额 + $amount = 0; + + foreach ($products as $product) { + $sku = $product['sku']; + + if ($coupon->isSupport($sku)) { + $passes = true; + + $amount += $sku->getRealPrice($user) * $product['quantity']; } } - } - foreach ($coupons as $coupon) { - // 这个券是否有可用的商品 - if (isset($couponSkus[$coupon->coupon_id])) { - //用户是否是vip - if ($user->isVip()) { - $totalAmount = collect($couponSkus[$coupon->coupon_id])->sum(function ($item) { - return bcmul($item['vip_price'], $item['num']); - }); - } else { - $totalAmount = collect($couponSkus[$coupon->coupon_id])->sum(function ($item) { - return bcmul($item['sell_price'], $item['num']); - }); - } - //针对这个券,可用的商品合计价格是否满足使用门槛 - if ($totalAmount >= $coupon->coupon_threshold) { - $availableCoupons[] = $coupon; - } + if ($passes && $amount >= $coupon->coupon_threshold) { + $availableCoupons[] = $coupon; } } - return collect($availableCoupons); - } - - /** - * 用户有效的优惠券 - * - * @param User $user - * @return collection - */ - public function userValidCoupons(User $user): collection - { - return UserCoupon::with('ranges')->where([ - 'user_id'=>$user->id, - ])->onlyUnuse()->get(); - } - - /** - * 这批商品中,这个券是否可用 - * - * @return boolean - */ - protected function isAvailableCouponToSomeSku(collection $skus, UserCoupon $coupon) - { - $res = false; - foreach ($skus as $sku) { - if ($sku instanceof ProductSku && $this->isAvailableCouponToSku($sku, $coupon)) { - $res = true; - break;//提前退出循环 - } - } - return $res; - } - - /** - * 这个商品是否可用这个券 - * - * @param array $productSku ['id', 'category_id', 'sell_price', 'vip_price', 'num'] - * @param Coupon $coupon - * @return boolean - */ - protected function isAvailableCouponToSku($sku, UserCoupon $coupon) - { - $res = false; - if ($coupon->ranges->count() == 0) {//如果没有规则则通用 - return true; - } - foreach ($coupon->ranges as $range) { - switch ($range->type) { - case 1://指定商品分类 - if (in_array($sku['category_id'], explode(',', $range->ranges))) { - $res = true; - } - break; - case 2://指定商品IDS - if (in_array($sku['id'], explode(',', $range->ranges))) { - $res = true; - } - break; - } - if ($res) {//如果可用提前跳出循环; - break; - } - } - return $res; + return $availableCoupons; } } diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..69e949bd --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,391 @@ +findOrFail($skuId); + + $product = [ + 'sku' => $sku, + 'quantity' => $quantity, + ]; + + return $this->verifyOrder($user, [$product], $shippingAddressId, $couponId); + } + + /** + * 确认购物车订单 + * + * @param \App\Models\User $user + * @param array $shoppingCartItemIds + * @param int|null $shippingAddressId + * @param int|null $couponId + * @return array + */ + public function verifyShoppingCartOrder(User $user, array $shoppingCartItemIds, ?int $shippingAddressId = null, ?int $couponId = null) + { + // 获取购买商品 + $products = $this->getProductsByShoppingCart($user, $shoppingCartItemIds); + + return $this->verifyOrder($user, $products, $shippingAddressId, $couponId); + } + + /** + * 确认订单 + * + * @param \App\Models\User $user + * @param array $products + * @param int|null $shippingAddressId + * @param int|null $couponId + * @return array + */ + protected function verifyOrder(User $user, array $products, ?int $shippingAddressId = null, ?int $couponId = null): array + { + // 获取收货地址 + $shippingAddress = $this->getShippingAddress($user, $shippingAddressId); + + // 优惠券 + $coupon = null; + + if ($couponId) { + $coupon = $user->coupons()->onlyUnuse()->findOrFail($couponId); + } + + $mapProducts = $this->mapProducts($user, $products, $coupon); + + // 是否支持配送 + $shippingSupported = true; + // 运费 + $shippingFee = 0; + + if ($shippingAddress) { + try { + $shippingFee = $this->calculateShippingFee($mapProducts, $shippingAddress); + } catch (ShippingNotSupportedException $e) { + $shippingFee = 0; + $shippingSupported = false; + } + } + + list( + $productsTotalAmount, + $vipDiscountAmount, + $couponDiscountAmount, + $totalAmount, + ) = $this->calculateFees($mapProducts); + + return [ + 'products' => collect($mapProducts)->map(function ($item) { + return [ + 'sku' => ProductSkuSimpleResource::make($item['sku']), + 'quantity' => $item['quantity'], + ]; + }), + 'coupons' => UserCouponResource::collection((new CouponService())->getAvailableCoupons($user, $products)), + 'shipping_address' => $shippingAddress ? ShippingAddressResource::make($shippingAddress) : null, // 收货地址 + 'shipping_supported' => $shippingSupported, + 'shipping_fee' => Numeric::trimTrailingZero(bcdiv($shippingFee, 100, 2)), // 运费 + 'products_total_amount' => Numeric::trimTrailingZero(bcdiv($productsTotalAmount, 100, 2)), // 商品总额 + 'vip_discount_amount' => Numeric::trimTrailingZero(bcdiv($vipDiscountAmount, 100, 2)), // 会员折扣金额 + 'coupon_discount_amount' => Numeric::trimTrailingZero(bcdiv($couponDiscountAmount, 100, 2)), // 优惠券折扣金额 + 'total_amount' => Numeric::trimTrailingZero(bcdiv($totalAmount + $shippingFee, 100, 2)), // 实付金额 + ]; + } + + /** + * 计算费用 + * + * @param array $products + * @return array + */ + protected function calculateFees(array $products) + { + $productsTotalAmount = 0; + $vipDiscountAmount = 0; + $couponDiscountAmount = 0; + + + foreach ($products as $product) { + $productsTotalAmount += $product['total_amount']; + $vipDiscountAmount += $product['vip_discount_amount']; + $couponDiscountAmount += $product['coupon_discount_amount']; + } + + // 订单总额 + $totalAmount = $productsTotalAmount - $vipDiscountAmount - $couponDiscountAmount; + + return [ + $productsTotalAmount, + $vipDiscountAmount, + $couponDiscountAmount, + $totalAmount, + ]; + } + + /** + * 计算运费 + * + * @param array $products + * @param \App\Models\ShippingAddress $shippingAddress + * @return int + */ + protected function calculateShippingFee(array $products, ShippingAddress $shippingAddress): int + { + // 运费 + $shippingFee = 0; + + $shippings = []; + + foreach ($products as $product) { + $sku = $product['sku']; + + if (is_null($sku->shipping_template_id)) { + continue; + } + + $shipping = $shippings[$sku->shipping_template_id] ?? [ + 'total_weight' => 0, + 'total_amount' => 0, + ]; + $shipping['total_weight'] += $sku->weight * $product['quantity']; + $shipping['total_amount'] += $product['total_amount'] - $product['vip_discount_amount'] - $product['coupon_discount_amount']; + + $shippings[$sku->shipping_template_id] = $shipping; + } + + $shippingService = new ShippingService(); + + foreach ($shippings as $templateId => $shipping) { + $shippingFee += $shippingService->countShippingAmount( + $shipping['total_weight'], + $shipping['total_amount'], + $templateId, + $shippingAddress->zone_id + ); + } + + return $shippingFee; + } + + /** + * 准备商品信息 + * + * @param \App\Models\User $user + * @param array $products + * @param \App\Models\UserCoupon|null $coupon + * @return array + * + * @throws \App\Exceptions\BizException + */ + protected function mapProducts(User $user, array $products, ?UserCoupon $coupon): array + { + $_products = collect($products)->map(function ($item) use ($user) { + $sku = $item['sku']; + + return array_merge($item, [ + // 优惠券折扣金额 + 'coupon_discount_amount' => 0, + // 会员折扣金额 + 'vip_discount_amount' => $this->calculateVipDiscountAmount( + $user, $sku, $item['quantity'] + ), + // 总金额 + 'total_amount' => $sku->sell_price * $item['quantity'], + ]); + }); + + if ($coupon === null) { + return $_products->all(); + } + + $couponDiscountAmounts = $this->getCouponDiscountAmounts($coupon, $_products->all()); + + return $_products->map(function ($item) use ($couponDiscountAmounts) { + $item['coupon_discount_amount'] = $couponDiscountAmounts[$item['sku']->id] ?? 0; + + return $item; + })->all(); + } + + /** + * 计算会员折扣金额 + * + * @param \App\Models\User $user + * @param \App\Models\ProductSku $sku + * @param int $quantity + * @return int + */ + protected function calculateVipDiscountAmount(User $user, ProductSku $sku, int $quantity) + { + $price = $sku->getRealPrice($user); + + if ($price > $sku->sell_price) { + return 0; + } + + return ($sku->sell_price - $price) * $quantity; + } + + /** + * 获取商品的优惠券折扣金额 + * + * @param \App\Models\UserCoupon $coupon + * @param array $products + * @return array + * + * @throws \App\Exceptions\BizException + */ + protected function getCouponDiscountAmounts(UserCoupon $coupon, array $products): array + { + // 启用的券的使用规则 + $coupon->loadMissing(['ranges' => function ($query) { + $query->isEnable(); + }]); + + // 可使用优惠券的商品总额 + $amounts = []; + + foreach ($products as $item) { + $sku = $item['sku']; + + if (! $coupon->isSupport($sku)) { + throw new BizException('优惠券不满足使用条件'); + } + + $amount = $item['total_amount'] - $item['vip_discount_amount']; + + // 仅保留商品真实总额大于0的商品 + if ($amount > 0) { + $amounts[$sku->id] = $amount; + } + } + + // 全部商品总额 + $totalAmount = array_sum($amounts); + + if ($coupon->coupon_threshold > $totalAmount) { + throw new BizException('优惠券不满足使用条件'); + } + + // 商品的券折扣金额 + $discountAmounts = []; + // 优惠券折扣总额 + $discountTotalAmount = 0; + // 未计算优惠金额的商品总数 + $lastCount = count($amounts); + + foreach ($amounts as $skuId => $amount) { + if ($coupon->isDiscountCoupon()) { + /* + |---------------------------------------- + | 计算折扣券的折扣金额 + |---------------------------------------- + */ + + $discountAmounts[$skuId] = (int) ($amount * $coupon->coupon_amount / 100); + } else { + /* + |---------------------------------------- + | 计算抵扣券的抵扣金额 + |---------------------------------------- + */ + + $couponAmount = $coupon->coupon_amount; + + // 如果券的抵用金额大于商品的实际金额,则以商品实际金额作为抵扣金额 + if ($couponAmount > $totalAmount) { + $couponAmount = $totalAmount; + } + + $discountAmounts[$skuId] = (int) ($couponAmount * $amount / $totalAmount); + $discountTotalAmount += $discountAmounts[$skuId]; + + $lastCount--; + + // 将券的剩余抵扣金额给最后一个商品 + if ($lastCount === 0) { + $discountAmounts[$skuId] += $couponAmount - $discountTotalAmount; + } + } + } + + return $discountAmounts; + } + + /** + * 根据购物车获取商品 + * + * @param \App\Models\User $user + * @param array $shoppingCartItemIds + * @return array + * + * @throws \App\Exceptions\BizException + */ + protected function getProductsByShoppingCart(User $user, array $shoppingCartItemIds): array + { + $shoppingCartItems = $user->shoppingCartItems()->findMany($shoppingCartItemIds); + + if ($shoppingCartItems->count() !== count($shoppingCartItemIds)) { + throw new BizException('购物车商品已丢失'); + } + + $shoppingCartItems->load('sku'); + + $lostShoppingCartItems = $shoppingCartItems->filter(function ($item) { + return $item->sku === null; + }); + + if ($lostShoppingCartItems->count() > 0) { + throw new BizException('购物车商品已失效'); + } + + return $shoppingCartItems->map(function ($item) { + return [ + 'sku' => $item->sku, + 'quantity' => $item->quantity, + ]; + })->all(); + } + + /** + * 获取收货地址 + * + * @param \App\Models\User $user + * @param int|null $shippingAddressId + * @return \App\Models\ShippingAddress|null + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + protected function getShippingAddress(User $user, ?int $shippingAddressId = null): ?ShippingAddress + { + if ($shippingAddressId) { + return $user->shippingAddresses()->findOrFail($shippingAddressId); + } + + return $user->shippingAddresses()->where('is_default', true)->first(); + } +} diff --git a/app/Services/ShippingService.php b/app/Services/ShippingService.php index f5adcc36..5b1fa660 100644 --- a/app/Services/ShippingService.php +++ b/app/Services/ShippingService.php @@ -2,7 +2,7 @@ namespace App\Services; -use App\Exceptions\BizException; +use App\Exceptions\ShippingNotSupportedException; use App\Models\ShippingRule; class ShippingService @@ -15,6 +15,8 @@ class ShippingService * @param integer $templateId * @param integer $zoneId * @return integer + * + * @throws \App\Exceptions\ShippingNotSupportedException */ public function countShippingAmount(int $weight, int $totalAmount, int $templateId, int $zoneId): int { @@ -54,7 +56,7 @@ class ShippingService } } if (!$canShipping) { - throw new BizException('当前选择的地址有部分商品不支持配送'); + throw new ShippingNotSupportedException(); } return $shipping_amount; diff --git a/resources/lang/zh_CN/models.php b/resources/lang/zh_CN/models.php index def87f5a..6c64436d 100644 --- a/resources/lang/zh_CN/models.php +++ b/resources/lang/zh_CN/models.php @@ -4,6 +4,7 @@ use App\Models\ProductSku; use App\Models\ProductSpu; use App\Models\ShippingAddress; use App\Models\ShoppingCartItem; +use App\Models\UserCoupon; use App\Models\Zone; return [ @@ -11,5 +12,6 @@ return [ ProductSpu::class => '商品', ProductSku::class => '商品', ShoppingCartItem::class => '商品', + UserCoupon::class => '优惠券', Zone::class => '地区', ];