From 7f4c44aa5ee1ad6a60cc03f7bdba9a7471ee7a46 Mon Sep 17 00:00:00 2001
From: Jing Li
Date: Fri, 3 Nov 2023 10:29:03 +0800
Subject: [PATCH] =?UTF-8?q?=E7=BA=BF=E4=B8=8B=E8=AE=A2=E5=8D=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controllers/OfflineOrderController.php | 192 ++++++++++++++
app/Admin/Repositories/OfflineOrder.php | 11 +
app/Admin/routes.php | 4 +
.../Controllers/OfflineOrderController.php | 76 ++++++
.../OfflineOrderPreviewController.php | 85 +++++++
app/Endpoint/Api/routes.php | 6 +
app/Enums/OfflineOrderStatus.php | 42 ++++
app/Enums/PayWay.php | 11 +
app/Models/OfflineOrder.php | 92 +++++++
app/Models/OfflineOrderItem.php | 31 +++
app/Models/OfflineOrderPreview.php | 25 ++
app/Providers/AppServiceProvider.php | 2 +
app/Services/OfflineOrderService.php | 238 ++++++++++++++++++
app/Services/PayService.php | 19 +-
...31_create_offline_order_previews_table.php | 35 +++
..._30_165623_create_offline_orders_table.php | 51 ++++
...51643_create_offline_order_items_table.php | 39 +++
database/seeders/AdminMenuSeeder.php | 5 +
database/seeders/AdminPermissionSeeder.php | 5 +
resources/lang/zh_CN/offline-order.php | 27 ++
20 files changed, 994 insertions(+), 2 deletions(-)
create mode 100644 app/Admin/Controllers/OfflineOrderController.php
create mode 100644 app/Admin/Repositories/OfflineOrder.php
create mode 100644 app/Endpoint/Api/Http/Controllers/OfflineOrderController.php
create mode 100644 app/Endpoint/Api/Http/Controllers/OfflineOrderPreviewController.php
create mode 100644 app/Enums/OfflineOrderStatus.php
create mode 100644 app/Models/OfflineOrder.php
create mode 100644 app/Models/OfflineOrderItem.php
create mode 100644 app/Models/OfflineOrderPreview.php
create mode 100644 app/Services/OfflineOrderService.php
create mode 100644 database/migrations/2023_10_30_133731_create_offline_order_previews_table.php
create mode 100644 database/migrations/2023_10_30_165623_create_offline_orders_table.php
create mode 100644 database/migrations/2023_10_31_151643_create_offline_order_items_table.php
create mode 100644 resources/lang/zh_CN/offline-order.php
diff --git a/app/Admin/Controllers/OfflineOrderController.php b/app/Admin/Controllers/OfflineOrderController.php
new file mode 100644
index 00000000..d0078a21
--- /dev/null
+++ b/app/Admin/Controllers/OfflineOrderController.php
@@ -0,0 +1,192 @@
+model()->orderBy('id', 'desc');
+
+ $grid->column('id')->sortable()->if(function () {
+ return Admin::user()->can('dcat.admin.offline_orders.show');
+ })->then(function (Column $column) {
+ $column->link(function ($value) {
+ return admin_route('offline_orders.show', ['offline_order' => $value]);
+ });
+ });
+ $grid->column('sn')->copyable();
+ $grid->column('user_id')->display(function () {
+ $nickname = $this->userInfo?->nickname ?? '---';
+ $avatar = $this->userInfo?->avatar ?? 'https://via.placeholder.com/45x45.png';
+ $phone = $this->user?->phone;
+ return <<
+ {$nickname}
+ {$phone}
+ HTML;
+ });
+ $grid->column('store_id')->display(fn() => $this->store?->title);
+ $grid->column('products_total_amount')->display(fn($v) => bcdiv($v, 100, 2))->prepend('¥');
+ $grid->column('discount_reduction_amount')->display(fn($v) => bcdiv($v, 100, 2))->prepend('¥');
+ $grid->column('points_deduction_amount')->display(fn($v) => bcdiv($v, 100, 2))->prepend('¥');
+ $grid->column('payment_amount')->display(fn($v) => bcdiv($v, 100, 2))->prepend('¥');
+ $grid->column('status')->display(fn($v) => $v->label());
+ $grid->column('payment_method')->display(fn($v) => $v?->dot());
+ $grid->column('payment_time');
+ $grid->column('created_at');
+
+ $grid->filter(function (Grid\Filter $filter) {
+ $filter->panel();
+ $filter->like('sn')->width(3);
+ $filter->where('user_id', function ($q) {
+ $q->where(function ($q) {
+ $q->whereHas('user', fn($q) => $q->where('phone', 'like', '%'.$this->input.'%'))
+ ->orWhereHas('userinfo', fn($q) => $q->where('nickname', 'like', '%'.$this->input.'%'));
+ });
+ })->width(3)->placeholder('昵称/手机号');
+ $filter->like('payment_sn')->width(3);
+ $filter->like('out_trade_no')->width(3);
+ $filter->equal('store_id')->select(Store::pluck('title', 'id'))->width(3);
+ $filter->where('type', function ($builder) {
+ if ($this->input == 1) {
+ $builder->where('points_deduction_amount', '>', 0);
+ } else {
+ $builder->where('points_deduction_amount', 0);
+ }
+ }, '类型')->select([1 => '积分订单', 2 => '其它订单'])->width(3);
+ $filter->in('status')->multipleSelect(OfflineOrderStatus::options())->width(3);
+ $filter->equal('payment_method')->select([
+ PayWay::WxpayMiniProgram->value => PayWay::WxpayMiniProgram->text(),
+ PayWay::None->value => PayWay::None->text(),
+ ])->width(3);
+ $filter->between('created_at')->dateTime()->width(6);
+ });
+
+ $user = Admin::user();
+
+ $grid->actions(function (Grid\Displayers\Actions $actions) use ($user) {
+ if ($user->can('dcat.admin.offline_orders.show')) {
+ $actions->disableView(false);
+ }
+ });
+
+ $grid->header(function ($collection) use ($grid) {
+ return tap(new Row(), function ($row) use ($grid) {
+ $query = OfflineOrder::query();
+ $grid->model()->getQueries()->unique()->each(function ($value) use (&$query) {
+ if (in_array($value['method'], ['paginate', 'get', 'orderBy', 'orderByDesc'], true)) {
+ return;
+ }
+
+ $query = call_user_func_array([$query, $value['method']], $value['arguments'] ?? []);
+ });
+ $productsTotalAmount = (clone $query)->sum('products_total_amount');
+ $discountReductionAmount = (clone $query)->sum('discount_reduction_amount');
+ $pointsDiscountAmount = (clone $query)->sum('points_deduction_amount');
+ $paymentAmount = (clone $query)->sum('payment_amount');
+
+ $row->column(2, new InfoBox('订单总额', bcdiv($productsTotalAmount, 100, 2), 'fa fa-ticket'));
+ $row->column(2, new InfoBox('折扣优惠', bcdiv($discountReductionAmount, 100, 2), 'fa fa-ticket'));
+ $row->column(2, new InfoBox('积分抵扣', bcdiv($pointsDiscountAmount, 100, 2), 'fa fa-ticket'));
+ $row->column(2, new InfoBox('实付金额', bcdiv($paymentAmount, 100, 2), 'fa fa-ticket'));
+ });
+ });
+
+ return $grid;
+ }
+
+ protected function detail($id)
+ {
+ return function (Row $row) use ($id) {
+ $row->column(5, function ($column) use ($id) {
+ $builder = OfflineOrderRepository::with(['user', 'userInfo', 'store', 'staff', 'staffInfo']);
+ $column->row(Show::make($id, $builder, function (Show $show) {
+ $show->row(function (Show\Row $show) {
+ $show->width(6)->field('sn');
+ $show->width(6)
+ ->field('phone')
+ ->as(fn () => $this->user?->phone);
+ $show->field('store_id')
+ ->as(fn () => $this->store?->title);
+ $show->field('staff_id')
+ ->as(fn () => $this->staffInfo?->nickname ?? $this->staff?->phone);
+ $show->field('products_total_amount')
+ ->as(fn ($v) => bcdiv($v, 100, 2))
+ ->prepend('¥');
+ $show->field('discount_reduction_amount')
+ ->as(fn ($v) => bcdiv($v, 100, 2))
+ ->prepend('-¥');
+ $show->field('points_deduction_amount')
+ ->as(fn ($v) => bcdiv($v, 100, 2))
+ ->prepend('-¥');
+ $show->field('payment_amount')
+ ->as(fn ($v) => bcdiv($v, 100, 2))
+ ->prepend('¥');
+ $show->field('status')
+ ->escape(false)
+ ->as(fn () => $this->status->label());
+ $show->field('payment_method')
+ ->escape(false)
+ ->as(fn () => $this->payment_method?->dot());
+ $show->field('payment_time');
+ $show->field('payment_sn');
+ $show->field('out_trade_no');
+ $show->field('created_at');
+ });
+ $show->panel()->tools(function (Show\Tools $tools) use ($show) {
+ $tools->disableEdit();
+ $tools->disableDelete();
+ });
+ }));
+ });
+ $row->column(7, function ($column) use ($id) {
+ $orderItemGrid = Grid::make(OfflineOrderItem::with(['productCategory'])->where('order_id', $id), function (Grid $grid) {
+ $grid->column('product_category_name')
+ ->display(fn() => $this->productCategory?->name ?: 'Unknown');
+ $grid->column('products_total_amount', '商品总额')
+ ->display(fn ($v) => bcdiv($v, 100, 2))->prepend('¥');
+ $grid->column('discount_reduction_amount', '折扣优惠')
+ ->display(fn ($v) => bcdiv($v, 100, 2))->prepend('¥');
+ $grid->column('points_deduction_amount', '积分抵扣')
+ ->display(fn ($v) => bcdiv($v, 100, 2))->prepend('¥');
+ $grid->column('payment_amount', '实付金额')
+ ->display(fn ($v) => bcdiv($v, 100, 2))->prepend('¥');
+ $grid->disableActions();
+ $grid->disablePagination();
+ $grid->disableRefreshButton();
+ });
+
+ $column->row(Box::make('订单明细', $orderItemGrid));
+ });
+ };
+ }
+}
diff --git a/app/Admin/Repositories/OfflineOrder.php b/app/Admin/Repositories/OfflineOrder.php
new file mode 100644
index 00000000..1e1e76e0
--- /dev/null
+++ b/app/Admin/Repositories/OfflineOrder.php
@@ -0,0 +1,11 @@
+ ['index', 'create', 'store', 'edit', 'update', 'destroy'],
])->names('offline_product_categories');
+ $router->resource('offline-orders', 'OfflineOrderController', [
+ 'only' => ['index', 'show'],
+ ])->names('offline_orders');
+
/** 调试接口 **/
// $router->get('test', 'HomeController@test');
diff --git a/app/Endpoint/Api/Http/Controllers/OfflineOrderController.php b/app/Endpoint/Api/Http/Controllers/OfflineOrderController.php
new file mode 100644
index 00000000..06fd21f8
--- /dev/null
+++ b/app/Endpoint/Api/Http/Controllers/OfflineOrderController.php
@@ -0,0 +1,76 @@
+validate([
+ 'order_preview_id' => ['bail', 'required', 'int'],
+ ]);
+
+ $preview = OfflineOrderPreview::findOrFail($request->input('order_preview_id'));
+
+ return $offlineOrderService->check($request->user(), $preview->payload['items']);
+ }
+
+ /**
+ * 创建订单
+ */
+ public function store(Request $request, OfflineOrderService $offlineOrderService)
+ {
+ $request->validate([
+ 'order_preview_id' => ['bail', 'required', 'int'],
+ 'points' => ['bail', 'nullable', 'numeric', 'min:0'],
+ ]);
+
+ try {
+ DB::beginTransaction();
+
+ $preview = OfflineOrderPreview::findOrFail($request->input('order_preview_id'));
+
+ $order = $offlineOrderService->create(
+ $request->user(),
+ $preview,
+ bcmul($request->input('points', 0), 100),
+ );
+
+ DB::commit();
+ } catch (Throwable $th) {
+ report($th);
+
+ throw $th;
+ }
+
+ return $order;
+ }
+
+ /**
+ * 订单付款
+ */
+ public function pay($id, Request $request)
+ {
+ $user = $request->user();
+
+ return DB::transaction(function () use ($id, $user) {
+ $order = OfflineOrder::where('user_id', $user->id)
+ ->where('id', $id)
+ ->firstOrFail();
+
+ return (new OfflineOrderService())->pay($order, PayWay::WxpayMiniProgram);
+ });
+ }
+}
diff --git a/app/Endpoint/Api/Http/Controllers/OfflineOrderPreviewController.php b/app/Endpoint/Api/Http/Controllers/OfflineOrderPreviewController.php
new file mode 100644
index 00000000..a3307a2b
--- /dev/null
+++ b/app/Endpoint/Api/Http/Controllers/OfflineOrderPreviewController.php
@@ -0,0 +1,85 @@
+validate([
+ 'store_id' => ['bail', 'required', 'int'],
+ 'items' => ['bail', 'required', 'array'],
+ 'items.*.product_category_id' => ['bail', 'required', 'int'],
+ 'items.*.products_total_amount' => ['bail', 'required', 'numeric', 'min:0', 'regex:/^([1-9]\d*|0)(\.\d{1,2})?$/'],
+ 'items.*.discount' => ['bail', 'nullable', 'numeric', 'gt:0', 'lt:10', 'regex:/^[0-9](\.\d{1,2})?$/'],
+ ]);
+
+ $user = $request->user();
+
+ if (! $user->userInfo->is_company) {
+ throw new BizException('非内部员工');
+ }
+
+ $store = Store::findOrFail($request->input('store_id'));
+
+ $preview = OfflineOrderPreview::create([
+ 'store_id' => $store->id,
+ 'staff_id' => $user->id,
+ 'payload' => ['items' => $request->input('items')],
+ ]);
+
+ $scene = http_build_query([
+ 'offline_order' => $preview->id,
+ ]);
+
+ // 生成小程序码
+ $app = Factory::miniProgram(config('wechat.mini_program.default'));
+
+ $response = $app->app_code->getUnlimit($scene, [
+ 'page' => 'pages/welcome/index',
+ 'check_path' => false,
+ 'env_version' => app()->isProduction() ? 'release' : $request->input('env_version', 'trial'),
+ 'width' => $request->input('width', 200),
+ ]);
+
+ // 保存小程序码
+ if ($response instanceof StreamResponse) {
+ $directory = 'offline-order-preview';
+ $filename = "{$preview->id}.png";
+
+ $disk = Storage::disk('public');
+
+ $response->save($disk->path($directory), $filename);
+
+ $preview->update(['qrcode' => $disk->url("{$directory}/{$filename}")]);
+
+ return response()->json([
+ 'id' => $preview->id,
+ 'qrcode' => $preview->qrcode,
+ ]);
+ }
+
+ logger('offline_order_preview 小程序码生成失败', $response);
+
+ throw new BizException('生成失败, 请重试');
+ }
+
+ public function show($id)
+ {
+ $preview = OfflineOrderPreview::findOrFail($id);
+
+ return response()->json([
+ 'id' => $preview->id,
+ 'qrcode' => $preview->qrcode,
+ ]);
+ }
+}
diff --git a/app/Endpoint/Api/routes.php b/app/Endpoint/Api/routes.php
index 7f0b2c03..c6132f15 100644
--- a/app/Endpoint/Api/routes.php
+++ b/app/Endpoint/Api/routes.php
@@ -221,6 +221,12 @@ Route::group([
// 抽奖活动抽奖
Route::post('draw-activities/{draw_activity}/draw', [App\Endpoint\Api\Http\Controllers\DrawActivityController::class, 'draw']);
Route::put('draw-activities/{draw_activity}/logs/{log}', [App\Endpoint\Api\Http\Controllers\DrawLogController::class, 'update']);
+
+ Route::get('offline-order-previews/{offline_order_preview}', [App\Endpoint\Api\Http\Controllers\OfflineOrderPreviewController::class, 'show']);
+ Route::post('offline-order-previews', [App\Endpoint\Api\Http\Controllers\OfflineOrderPreviewController::class, 'store']);
+ Route::post('offline-orders/check', [App\Endpoint\Api\Http\Controllers\OfflineOrderController::class, 'check']);
+ Route::post('offline-orders', [App\Endpoint\Api\Http\Controllers\OfflineOrderController::class, 'store']);
+ Route::post('offline-orders/{offline_order}/pay', [App\Endpoint\Api\Http\Controllers\OfflineOrderController::class, 'pay']);
});
// 微信小程序
diff --git a/app/Enums/OfflineOrderStatus.php b/app/Enums/OfflineOrderStatus.php
new file mode 100644
index 00000000..eb9d198e
--- /dev/null
+++ b/app/Enums/OfflineOrderStatus.php
@@ -0,0 +1,42 @@
+get($this->color());
+
+ return "{$this->text()}";
+ }
+
+ public function text()
+ {
+ return static::options()[$this->value] ?? 'Unknown';
+ }
+
+ public function color()
+ {
+ return match ($this) {
+ static::Pending => 'primary',
+ static::Paid => 'success',
+ static::Revoked => '#b3b9bf',
+ default => 'warning',
+ };
+ }
+
+ public static function options(): array
+ {
+ return [
+ self::Pending->value => '待付款',
+ self::Paid->value => '已付款',
+ self::Revoked->value => '已取消',
+ ];
+ }
+}
diff --git a/app/Enums/PayWay.php b/app/Enums/PayWay.php
index f7d92b23..51d722dc 100644
--- a/app/Enums/PayWay.php
+++ b/app/Enums/PayWay.php
@@ -2,6 +2,8 @@
namespace App\Enums;
+use Dcat\Admin\Admin;
+
enum PayWay: string {
case None = 'none';
case Offline = 'offline';
@@ -17,6 +19,15 @@ enum PayWay: string {
// 阿里支付
case AlipayApp = 'alipay_app';
+ public function dot()
+ {
+ $color = $this->color();
+
+ $color = Admin::color()->get($color, $color);
+
+ return ' '.$this->text() ?? 'Unknown';
+ }
+
public function color()
{
return match ($this) {
diff --git a/app/Models/OfflineOrder.php b/app/Models/OfflineOrder.php
new file mode 100644
index 00000000..4aee215f
--- /dev/null
+++ b/app/Models/OfflineOrder.php
@@ -0,0 +1,92 @@
+ 0,
+ 'points_deduction_amount' => 0,
+ 'status' => OfflineOrderStatus::Pending,
+ ];
+
+ protected $casts = [
+ 'payment_time' => 'datetime',
+ 'status' => OfflineOrderStatus::class,
+ 'payment_method' => PayWay::class,
+ 'revoked_at' => 'datetime',
+ ];
+
+ protected $fillable = [
+ 'user_id',
+ 'store_id',
+ 'staff_id',
+ 'sn',
+ 'products_total_amount',
+ 'discount_reduction_amount',
+ 'points_deduction_amount',
+ 'payment_amount',
+ 'payment_sn',
+ 'payment_method',
+ 'payment_time',
+ 'out_trade_no',
+ 'status',
+ 'revoked_at',
+ 'orderable_type',
+ 'orderable_id',
+ ];
+
+ public function store()
+ {
+ return $this->belongsTo(Store::class);
+ }
+
+ public function staff()
+ {
+ return $this->belongsTo(User::class, 'staff_id');
+ }
+
+ public function staffInfo()
+ {
+ return $this->belongsTo(UserInfo::class, 'staff_id', 'user_id');
+ }
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function userInfo()
+ {
+ return $this->belongsTo(UserInfo::class, 'user_id', 'user_id');
+ }
+
+ public function payLogs()
+ {
+ return $this->morphMany(PayLog::class, 'payable');
+ }
+
+ public function isPending(): bool
+ {
+ return $this->status === OfflineOrderStatus::Pending;
+ }
+
+ public function isPaid(): bool
+ {
+ return $this->status === OfflineOrderStatus::Paid;
+ }
+
+ public function isRevoked(): bool
+ {
+ return $this->status === OfflineOrderStatus::Revoked;
+ }
+}
diff --git a/app/Models/OfflineOrderItem.php b/app/Models/OfflineOrderItem.php
new file mode 100644
index 00000000..588f9cda
--- /dev/null
+++ b/app/Models/OfflineOrderItem.php
@@ -0,0 +1,31 @@
+ 0,
+ 'points_deduction_amount' => 0,
+ ];
+
+ protected $fillable = [
+ 'order_id',
+ 'product_category_id',
+ 'products_total_amount',
+ 'discount_reduction_amount',
+ 'points_deduction_amount',
+ 'payment_amount',
+ ];
+
+ public function productCategory()
+ {
+ return $this->belongsTo(OfflineProductCategory::class, 'product_category_id');
+ }
+}
diff --git a/app/Models/OfflineOrderPreview.php b/app/Models/OfflineOrderPreview.php
new file mode 100644
index 00000000..151709c9
--- /dev/null
+++ b/app/Models/OfflineOrderPreview.php
@@ -0,0 +1,25 @@
+ 'json',
+ ];
+
+ protected $fillable = [
+ 'store_id', 'staff_id', 'payload', 'qrcode',
+ ];
+
+ public function store()
+ {
+ return $this->belongsTo(Store::class);
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 28798ed3..6f20de05 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -58,6 +58,8 @@ class AppServiceProvider extends ServiceProvider
'user_vip' => \App\Models\UserVip::class,
'draw_log' => \App\Models\DrawLog::class,
'store_stock_batch' => \App\Models\Store\StockBatch::class,
+ 'offline_order' => \App\Models\OfflineOrder::class,
+ 'offline_order_preview' => \App\Models\OfflineOrderPreview::class,
]);
JsonResource::withoutWrapping();
diff --git a/app/Services/OfflineOrderService.php b/app/Services/OfflineOrderService.php
new file mode 100644
index 00000000..316b404c
--- /dev/null
+++ b/app/Services/OfflineOrderService.php
@@ -0,0 +1,238 @@
+mapItems($orderPreview->payload['items']);
+
+ [
+ $productsTotalAmount,
+ $discountReductionAmount,
+ $paymentAmount,
+ ] = $this->calculateFees($items);
+
+ // 积分抵扣金额
+ $pointsDeductionAmount = $points;
+
+ $paymentAmount -= $pointsDeductionAmount;
+ if ($paymentAmount < 0) {
+ $paymentAmount = 0;
+ }
+
+ do {
+ $sn = serial_number();
+ } while(OfflineOrder::where('sn', $sn)->exists());
+
+ /** @var \App\Models\OfflineOrder */
+ $order = OfflineOrder::create([
+ 'user_id' => $user->id,
+ 'store_id' => $orderPreview->store_id,
+ 'staff_id' => $orderPreview->staff_id,
+ 'sn' => $sn,
+ 'products_total_amount' => $productsTotalAmount,
+ 'discount_reduction_amount' => $discountReductionAmount,
+ 'points_deduction_amount' => $pointsDeductionAmount,
+ 'payment_amount' => $paymentAmount,
+ 'status' => OfflineOrderStatus::Pending,
+ 'orderable_type' => $orderPreview->getMorphClass(),
+ 'orderable_id' => $orderPreview->id,
+ ]);
+
+ $this->insertOrderItems($order, $items);
+
+ // 扣除积分
+ if ($points > 0) {
+ (new PointService)->change($user, -$points, PointLogAction::Consumption, "线下订单{$order->sn}使用积分", $order);
+ }
+
+ if ($order->payment_amount === 0) {
+ $this->pay($order, PayWay::None);
+
+ $order->refresh();
+ }
+
+ return $order;
+ }
+
+ public function check(User $user, array $items): array
+ {
+ $productCategoryIds = collect($items)->pluck('product_category_id');
+
+ $productCategories = OfflineProductCategory::query()
+ ->whereIn('id', $productCategoryIds->all())
+ ->get()
+ ->keyBy('id');
+
+ if ($productCategories->count() != $productCategoryIds->count()) {
+ throw new BizException('商品分类异常');
+ }
+
+ $mapItems = $this->mapItems($items);
+
+ [
+ $productsTotalAmount,
+ $discountReductionAmount,
+ $paymentAmount,
+ ] = $this->calculateFees($mapItems);
+
+ //---------------------------------------
+ // 积分当钱花
+ //---------------------------------------
+ $remainingPoints = $user->userInfo->points; // 用户剩余积分
+ $availablePoints = $paymentAmount > $remainingPoints ? $remainingPoints : $paymentAmount; // 可用积分
+
+ return [
+ 'items' => collect($mapItems)->map(fn($item) => [
+ 'product_category' => $productCategories->get($item['product_category_id']),
+ 'discount_reduction_amount' => bcdiv($item['discount_reduction_amount'], 100, 2),
+ 'products_total_amount' => bcdiv($item['products_total_amount'], 100, 2),
+ 'payment_amount' => bcdiv($item['payment_amount'], 100, 2),
+ ]),
+ 'products_total_amount' => bcdiv($productsTotalAmount, 100, 2),
+ 'discount_reduction_amount' => bcdiv($discountReductionAmount, 100, 2),
+ 'payment_amount' => bcdiv($paymentAmount, 100, 2),
+ 'remaining_points' => bcdiv($remainingPoints, 100, 2), // 剩余积分
+ 'available_points' => bcdiv($availablePoints, 100, 2), // 可用积分
+ 'points_discount_amount' => bcdiv($availablePoints, 100, 2), // 积分抵扣金额
+ ];
+ }
+
+ public function pay(OfflineOrder $order, PayWay $payWay)
+ {
+ if (! $order->isPending()) {
+ throw new BizException('订单状态不是待付款');
+ }
+
+ $payLog = $order->payLogs()->create([
+ 'pay_way' => $payWay,
+ ]);
+
+ $data = null;
+
+ if ($order->payment_amount === 0) {
+ (new PayService())->handleSuccess($payLog, [
+ 'pay_at' => now(),
+ ]);
+ } elseif ($payLog->isWxpay()) {
+ if (is_null($tradeType = WxpayTradeType::tryFromPayWay($payLog->pay_way))) {
+ throw new BizException('支付方式 非法');
+ }
+ $params = [
+ 'body' => app_settings('app.app_name').'-线下订单',
+ 'out_trade_no' => $payLog->pay_sn,
+ 'total_fee' => $order->payment_amount,
+ 'trade_type' => $tradeType->value,
+ ];
+
+ if ($payLog->pay_way === PayWay::WxpayMiniProgram) {
+ $socialite = SocialiteUser::where([
+ 'user_id' => $order->user_id,
+ 'socialite_type' => SocialiteType::WechatMiniProgram,
+ ])->first();
+
+ if ($socialite === null) {
+ throw new BizException('未绑定微信小程序');
+ }
+
+ $params['openid'] = $socialite->socialite_id;
+ }
+
+ $data = (new WxpayService())->pay($params, match ($payLog->pay_way) {
+ PayWay::WxpayMiniProgram => 'mini_program',
+ default => 'default',
+ });
+ }
+
+ return [
+ 'pay_way' => $payLog->pay_way,
+ 'data' => $data,
+ ];
+ }
+
+ protected function insertOrderItems(OfflineOrder $order, array $items)
+ {
+ $remainingPointDiscountAmount = $order->points_deduction_amount;
+
+ OfflineOrderItem::insert(
+ collect($items)->map(function ($item) use ($order, &$remainingPointDiscountAmount) {
+ $pointsDeductionAmount = $item['payment_amount'];
+ if ($item['payment_amount'] > $remainingPointDiscountAmount) {
+ $pointsDeductionAmount = $remainingPointDiscountAmount;
+ }
+ $remainingPointDiscountAmount -= $pointsDeductionAmount;
+
+ return [
+ 'order_id' => $order->id,
+ 'product_category_id' => $item['product_category_id'],
+ 'products_total_amount' => $item['products_total_amount'],
+ 'discount_reduction_amount' => $item['discount_reduction_amount'],
+ 'points_deduction_amount' => $pointsDeductionAmount,
+ 'payment_amount' => $item['payment_amount'] - $pointsDeductionAmount,
+ 'created_at' => $order->created_at,
+ 'updated_at' => $order->updated_at,
+ ];
+ })->all()
+ );
+ }
+
+ protected function mapItems(array $items): array
+ {
+ return collect($items)->map(function ($item) {
+ $productsTotalAmount = bcmul($item['products_total_amount'], 100);
+ if ($productsTotalAmount < 0) {
+ throw new BizException('商品总额不能小于0');
+ }
+
+ $discountReductionAmount = 0;
+ if (is_numeric($item['discount'])) {
+ if ($item['discount'] <= 0) {
+ throw new BizException('折扣必须大于0');
+ } elseif ($item['discount'] >= 10) {
+ throw new BizException('折扣必须小于10');
+ }
+ $discount = bcdiv($item['discount'], 10, 3);
+ $discountReductionAmount = round(bcmul($productsTotalAmount, $discount, 2));
+ }
+
+ return [
+ 'product_category_id' => $item['product_category_id'],
+ 'products_total_amount' => (int) $productsTotalAmount,
+ 'discount_reduction_amount' => (int) $discountReductionAmount,
+ 'payment_amount' => (int) ($productsTotalAmount - $discountReductionAmount),
+ ];
+ })->all();
+ }
+
+ protected function calculateFees(array $items)
+ {
+ $totalProductsTotalAmount = 0;
+ $totalDiscountReductionAmount = 0;
+ $totalPaymentAmount = 0;
+
+ foreach ($items as $item) {
+ $totalProductsTotalAmount += $item['products_total_amount'];
+ $totalDiscountReductionAmount += $item['discount_reduction_amount'];
+ $totalPaymentAmount += $item['payment_amount'];
+ }
+
+ return [$totalProductsTotalAmount, $totalDiscountReductionAmount, $totalPaymentAmount];
+ }
+}
diff --git a/app/Services/PayService.php b/app/Services/PayService.php
index f6a3bea2..b27716d4 100644
--- a/app/Services/PayService.php
+++ b/app/Services/PayService.php
@@ -2,10 +2,11 @@
namespace App\Services;
+use App\Enums\OfflineOrderStatus;
use App\Exceptions\BizException;
use App\Exceptions\InvalidPaySerialNumberException;
-use App\Models\{Order, OrderPre};
use App\Models\PayLog;
+use App\Models\{OfflineOrder, Order, OrderPre};
class PayService
{
@@ -70,7 +71,21 @@ class PayService
'out_trade_no' => $payLog->out_trade_no,
'status' => Order::STATUS_PAID,
]);
- } else if ($payable instanceof \App\Models\UserVip) {
+ } elseif ($payable instanceof OfflineOrder) {
+ if ($payable->isPaid()) {
+ throw new BizException('订单已支付');
+ } elseif ($payable->isRevoked()) {
+ throw new BizException('订单取消');
+ }
+
+ $payable->update([
+ 'payment_sn' => $payLog->pay_sn,
+ 'payment_method' => $payLog->pay_way,
+ 'payment_time' => $payLog->pay_at,
+ 'out_trade_no' => $payLog->out_trade_no,
+ 'status' => OfflineOrderStatus::Paid,
+ ]);
+ } elseif ($payable instanceof \App\Models\UserVip) {
(new \App\Services\VipService())->success($payable, ['pay_at' => $payLog->pay_at]);
}
diff --git a/database/migrations/2023_10_30_133731_create_offline_order_previews_table.php b/database/migrations/2023_10_30_133731_create_offline_order_previews_table.php
new file mode 100644
index 00000000..20fa0a64
--- /dev/null
+++ b/database/migrations/2023_10_30_133731_create_offline_order_previews_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->unsignedBigInteger('store_id')->nullable()->comment('门店ID');
+ $table->unsignedBigInteger('staff_id')->nullable()->comment('店员ID');
+ $table->text('payload')->nullable();
+ $table->string('qrcode')->nullable()->comment('小程序码');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('offline_order_previews');
+ }
+}
diff --git a/database/migrations/2023_10_30_165623_create_offline_orders_table.php b/database/migrations/2023_10_30_165623_create_offline_orders_table.php
new file mode 100644
index 00000000..5029b223
--- /dev/null
+++ b/database/migrations/2023_10_30_165623_create_offline_orders_table.php
@@ -0,0 +1,51 @@
+id();
+ $table->unsignedBigInteger('user_id')->comment('用户ID');
+ $table->unsignedBigInteger('store_id')->nullable()->comment('门店ID');
+ $table->unsignedBigInteger('staff_id')->nullable()->comment('店员ID');
+ $table->string('sn')->comment('订单号');
+ $table->unsignedBigInteger('products_total_amount')->default(0)->comment('订单总额');
+ $table->unsignedBigInteger('discount_reduction_amount')->default(0)->comment('折扣减免金额');
+ $table->unsignedBigInteger('points_deduction_amount')->default(0)->comment('积分抵扣金额');
+ $table->unsignedBigInteger('payment_amount')->default(0)->comment('应付金额');
+ $table->string('payment_sn')->nullable()->comment('支付单号');
+ $table->string('payment_method')->nullable()->comment('支付方式');
+ $table->timestamp('payment_time')->nullable()->comment('付款时间');
+ $table->string('out_trade_no')->nullable()->comment('外部交易单号');
+ $table->tinyInteger('status')->default(0)->comment('状态: 0 待付款, 1 已付款');
+ $table->timestamp('revoked_at')->nullable()->comment('撤销时间');
+ $table->nullableMorphs('orderable');
+ $table->timestamps();
+
+ $table->index('user_id');
+ $table->index('store_id');
+ $table->index('staff_id');
+ $table->index('status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('offline_orders');
+ }
+}
diff --git a/database/migrations/2023_10_31_151643_create_offline_order_items_table.php b/database/migrations/2023_10_31_151643_create_offline_order_items_table.php
new file mode 100644
index 00000000..4fff8bcd
--- /dev/null
+++ b/database/migrations/2023_10_31_151643_create_offline_order_items_table.php
@@ -0,0 +1,39 @@
+id();
+ $table->unsignedBigInteger('order_id')->comment('线下订单ID');
+ $table->unsignedBigInteger('product_category_id')->comment('商品分类ID');
+ $table->unsignedBigInteger('products_total_amount')->default(0)->comment('商品总额');
+ $table->unsignedBigInteger('discount_reduction_amount')->default(0)->comment('折扣减免金额');
+ $table->unsignedBigInteger('points_deduction_amount')->default(0)->comment('积分抵扣金额');
+ $table->unsignedBigInteger('payment_amount')->default(0)->comment('应付金额');
+ $table->timestamps();
+
+ $table->index('order_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('offline_order_items');
+ }
+}
diff --git a/database/seeders/AdminMenuSeeder.php b/database/seeders/AdminMenuSeeder.php
index bce0c3d9..7c246c10 100644
--- a/database/seeders/AdminMenuSeeder.php
+++ b/database/seeders/AdminMenuSeeder.php
@@ -368,6 +368,11 @@ class AdminMenuSeeder extends Seeder
'icon' => '',
'uri' => 'offline-product-categories',
],
+ [
+ 'title' => '线下订单',
+ 'icon' => '',
+ 'uri' => 'offline-orders',
+ ],
],
],
];
diff --git a/database/seeders/AdminPermissionSeeder.php b/database/seeders/AdminPermissionSeeder.php
index 9e0f9373..83c47689 100644
--- a/database/seeders/AdminPermissionSeeder.php
+++ b/database/seeders/AdminPermissionSeeder.php
@@ -381,6 +381,11 @@ class AdminPermissionSeeder extends Seeder
'curd' => ['index', 'create', 'store', 'edit', 'update', 'destroy'],
'children' => [],
],
+ 'offline_orders' => [
+ 'name' => '线下订单 - 线下订单',
+ 'curd' => ['index', 'show'],
+ 'children' => [],
+ ],
];
// try {
// DB::begintransaction();
diff --git a/resources/lang/zh_CN/offline-order.php b/resources/lang/zh_CN/offline-order.php
new file mode 100644
index 00000000..023d8615
--- /dev/null
+++ b/resources/lang/zh_CN/offline-order.php
@@ -0,0 +1,27 @@
+ [
+ 'OfflineOrder' => '线下订单',
+ 'offline-orders' => '线下订单',
+ ],
+ 'fields' => [
+ 'sn' => '订单号',
+ 'user_id' => '用户',
+ 'store_id' => '门店',
+ 'staff_id' => '店员',
+ 'products_total_amount' => '订单总额',
+ 'discount_reduction_amount' => '折扣优惠',
+ 'points_deduction_amount' => '积分抵扣',
+ 'payment_amount' => '实付金额',
+ 'product_category_name' => '商品分类',
+ 'phone' => '手机号',
+ 'payment_sn' => '支付单号',
+ 'payment_method' => '支付方式',
+ 'payment_time' => '支付时间',
+ 'out_trade_no' => '外部交易单号',
+ 'status' => '状态',
+ ],
+ 'options' => [
+ ],
+];