diff --git a/database/2022_08_15_083640_create_orders_table.php b/database/2022_08_15_083640_create_orders_table.php index 892943d..e373296 100644 --- a/database/2022_08_15_083640_create_orders_table.php +++ b/database/2022_08_15_083640_create_orders_table.php @@ -32,6 +32,7 @@ return new class extends Migration $table->unsignedInteger('pay_status')->default(0)->comment('支付状态'); $table->unsignedInteger('refund_status')->default(0)->comment('退款状态'); + $table->decimal('refund_money', 12, 2)->default(0)->comment('已退款金额'); $table->unsignedInteger('ship_status')->default(0)->comment('配送状态'); $table->string('ship_way')->nullable()->comment('配送方式'); diff --git a/database/2022_10_11_121445_create_order_refunds_table.php b/database/2022_10_11_121445_create_order_refunds_table.php new file mode 100644 index 0000000..8969a08 --- /dev/null +++ b/database/2022_10_11_121445_create_order_refunds_table.php @@ -0,0 +1,43 @@ +id(); + $table->unsignedBigInteger('order_id')->comment('订单ID, orders.id'); + $table->string('sn')->comment('退款流水号'); + + $table->unsignedInteger('refund_status')->default(0)->comment('退款状态'); + $table->string('refund_way')->nullable()->comment('退款方式'); + $table->decimal('refund_money', 12, 2)->nullable()->comment('退款金额'); + $table->dateTime('finish_at')->nullable()->comment('完成时间'); + $table->string('refund_reason')->nullable()->comment('退款原因'); + $table->json('refund_data')->nullable()->comment('退款备注'); + + $table->timestamps(); + + $table->comment('订单退款'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('order_refunds'); + } +}; diff --git a/lang/en/refund.php b/lang/en/refund.php new file mode 100644 index 0000000..ca5d8ed --- /dev/null +++ b/lang/en/refund.php @@ -0,0 +1,5 @@ + [ + 'Refund' => '退款申请', + 'order-refunds' => '退款申请', + ], + 'fields' => [ + 'order_id' => '订单', + 'order' => [ + 'sn' => '订单号' + ], + 'sn' => '退款单号', + 'refund_status' => '退款状态', + 'refund_way' => '退款方式', + 'refund_money' => '退款金额', + 'finish_at' => '完成时间', + 'refund_reason' => '原因', + 'refund_data' => '其他', + 'created_at' => '申请时间', + ] +]; diff --git a/routes/admin.php b/routes/admin.php index 2b07f5f..451dbed 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -9,4 +9,5 @@ Route::group([ 'middleware' => config('admin.route.middleware'), ], function () { Route::resource('orders', OrderController::class)->only(['index', 'show', 'destroy'])->names('dcat.admin.order'); + Route::resource('order-refunds', RefundController::class)->only(['index', 'show', 'edit', 'update'])->names('dcat.admin.order_refunds'); }); diff --git a/src/Action/ShowRefund.php b/src/Action/ShowRefund.php new file mode 100644 index 0000000..96464b1 --- /dev/null +++ b/src/Action/ShowRefund.php @@ -0,0 +1,37 @@ +parent->model(); + + return Modal::make() + ->lg() + ->title($this->title) + ->body(RefundForm::make()->payload(['id' => $model->id, 'pay_money' => $model->pay_money, 'refund_money' => $model->refund_money])) + ->button(''); + } + + protected function authorize($user): bool + { + return $user->can('dcat.admin.orders.refund'); + } + + public function allowed() + { + $model = $this->parent->model(); + + return $model->canRefund(); + } +} diff --git a/src/Enums/OrderStatus.php b/src/Enums/OrderStatus.php index c70b15c..c7519a3 100644 --- a/src/Enums/OrderStatus.php +++ b/src/Enums/OrderStatus.php @@ -15,6 +15,10 @@ enum OrderStatus: int case PayFail = 7; case Cancel = 8; + case Refunding = 9; + case Refund = 10; + case RefundFull = 11; + public static function options() { return [ @@ -31,6 +35,10 @@ enum OrderStatus: int self::PayFail->value => '支付失败', self::Cancel->value => '已取消', + + self::Refunding->value => '退款中', + self::Refund->value => '已退款(部分)', + self::RefundFull->value => '已退款(全额)', ]; } diff --git a/src/Enums/RefundStatus.php b/src/Enums/RefundStatus.php index ab5d82e..9ba671a 100644 --- a/src/Enums/RefundStatus.php +++ b/src/Enums/RefundStatus.php @@ -17,7 +17,7 @@ enum RefundStatus: int { return [ self::None->value => '未申请', - self::Apply->value => '已申请', + self::Apply->value => '待审核', self::Processing->value => '处理中', self::Success->value => '退款成功', self::Fail->value => '退款失败', diff --git a/src/Enums/RefundWay.php b/src/Enums/RefundWay.php new file mode 100644 index 0000000..60c8219 --- /dev/null +++ b/src/Enums/RefundWay.php @@ -0,0 +1,48 @@ +value => '线下', + self::Origin->value => '原路退回', + ]; + } + + public function text() + { + return data_get(self::options(), $this->value); + } + + public function color() + { + return match ($this) { + static::Offline => 'warning', + static::Origin => 'success', + }; + } + + public function label() + { + $color = $this->color(); + + $name = $this->text(); + + return "{$name}"; + } + + public function dot() + { + $color = $this->color(); + + $name = $this->text(); + + return "  {$name}"; + } +} diff --git a/src/Form/RefundForm.php b/src/Form/RefundForm.php new file mode 100644 index 0000000..cda7d2c --- /dev/null +++ b/src/Form/RefundForm.php @@ -0,0 +1,66 @@ + false, 'submit' => true]; + + public function handle(array $input) + { + try { + DB::beginTransaction(); + $order = Order::findOrFail($this->payload['id']); + $money = $input['refund_money']; + $attributes = Arr::only($input, ['refund_reason', 'refund_way', 'refund_money', 'refund_sn']); + $info = RefundService::make()->apply($order, $attributes); + + $admin = Admin::user(); + $attributes['refund_id'] = $info->id; + $order->options()->create([ + 'user_type' => $admin->getMorphClass(), + 'user_id' => $admin->id, + 'description' => '管理员: '.$admin->name.' 申请退款: ' . $money, + 'attribute' => $attributes, + ]); + DB::commit(); + return $this->response()->success('申请成功, 等待审核')->refresh(); + } catch (OrderException $e) { + DB::rollBack(); + return $this->response()->error($e->getMessage()); + } + } + + public function form() + { + // 支付金额 + $payMoney = $this->payload['pay_money']; + // 已退款金额 + $refundedMoney = $this->payload['refund_money']; + $this->text('refund_reason', __('dcat-admin-order::refund.fields.refund_reason'))->required(); + $this->select('refund_way', __('dcat-admin-order::refund.fields.refund_way'))->options(RefundWay::options())->required(); + $this->currency('refund_money', __('dcat-admin-order::refund.fields.refund_money'))->symbol('¥')->help('最大值: ' . floatval($payMoney - $refundedMoney)); + $this->text('refund_sn', __('dcat-admin-order::refund.fields.sn')); + } + + public function default() + { + return [ + 'refund_way' => RefundWay::Origin, + 'refund_money' => floatval($this->payload['pay_money'] - $this->payload['refund_money']) + ]; + } +} \ No newline at end of file diff --git a/src/Http/Admin/OrderController.php b/src/Http/Admin/OrderController.php index 9d6d04a..32dcd5c 100644 --- a/src/Http/Admin/OrderController.php +++ b/src/Http/Admin/OrderController.php @@ -15,14 +15,6 @@ use Dcat\Admin\Show; use Dcat\Admin\Show\Tools; use Dcat\Admin\Widgets\Box; use Illuminate\Support\Arr; -use Peidikeji\Order\Action\ShowCancel; -use Peidikeji\Order\Action\ShowDelete; -use Peidikeji\Order\Action\ShowPay; -use Peidikeji\Order\Action\ShowPayQuery; -use Peidikeji\Order\Action\ShowPrice; -use Peidikeji\Order\Action\ShowReceive; -use Peidikeji\Order\Action\ShowRemarks; -use Peidikeji\Order\Action\ShowShip; use Peidikeji\Order\Enums\OrderStatus; use Peidikeji\Order\Enums\PayStatus; use Peidikeji\Order\Models\Order; @@ -44,9 +36,9 @@ class OrderController extends AdminController $grid->filter(function (Filter $filter) { $filter->panel(); - $filter->like('sn')->width(4); - $filter->equal('user_id')->select()->ajax('api/user?_paginate=1')->model(User::class, 'id', 'phone')->width(4); - $filter->where('order_status', fn($q) => $q->filter(['status' => $this->input]))->select(OrderStatus::options())->width(4); + $filter->like('sn')->width(3); + $filter->equal('user_id')->select()->ajax('api/user?_paginate=1')->model(User::class, 'id', 'phone')->width(3); + $filter->where('order_status', fn($q) => $q->filter(['status' => $this->input]))->select(OrderStatus::options())->width(3); $filter->whereBetween('created_at', function ($q) { $start = data_get($this->input, 'start'); $start = $start ? Carbon::createFromFormat('Y-m-d', $start) : null; @@ -60,7 +52,7 @@ class OrderController extends AdminController } else if ($end) { $q->where('created_at', '<=', $end); } - })->date()->width(4); + })->date()->width(6); }); $grid->column('sn')->copyable(); @@ -128,14 +120,15 @@ class OrderController extends AdminController $show->width(6)->field('remarks'); }); $show->tools(function (Tools $tools) { - $tools->append(new ShowCancel()); - $tools->append(new ShowPay()); - $tools->append(new ShowPayQuery()); - $tools->append(new ShowShip()); - $tools->append(new ShowReceive()); - $tools->append(new ShowDelete()); - $tools->append(new ShowPrice()); - $tools->append(new ShowRemarks()); + $tools->append(new \Peidikeji\Order\Action\ShowCancel()); + $tools->append(new \Peidikeji\Order\Action\ShowPay()); + $tools->append(new \Peidikeji\Order\Action\ShowPayQuery()); + $tools->append(new \Peidikeji\Order\Action\ShowShip()); + $tools->append(new \Peidikeji\Order\Action\ShowReceive()); + $tools->append(new \Peidikeji\Order\Action\ShowDelete()); + $tools->append(new \Peidikeji\Order\Action\ShowPrice()); + $tools->append(new \Peidikeji\Order\Action\ShowRefund()); + $tools->append(new \Peidikeji\Order\Action\ShowRemarks()); }); })); diff --git a/src/Http/Admin/RefundController.php b/src/Http/Admin/RefundController.php new file mode 100644 index 0000000..01454c7 --- /dev/null +++ b/src/Http/Admin/RefundController.php @@ -0,0 +1,101 @@ +model()->sort(); + + $grid->column('order.sn'); + $grid->column('sn'); + $grid->column('refund_reason'); + $grid->column('refund_money'); + $grid->column('refund_status')->display(fn() => $this->refund_status->label()); + $grid->column('created_at'); + + $grid->filter(function (Filter $filter) { + $filter->panel(); + $filter->like('order.sn')->width(3); + $filter->like('sn')->width(3); + $filter->equal('refund_status')->select(RefundStatus::options())->width(3); + $filter->whereBetween('created_at', function ($q) { + $start = data_get($this->input, 'start'); + $start = $start ? Carbon::createFromFormat('Y-m-d', $start) : null; + $end = data_get($this->input, 'end'); + $end = $end ? Carbon::createFromFormat('Y-m-d', $end) : null; + if ($start) { + if ($end) { + $q->whereBetween('created_at', [$start, $end]); + } + $q->where('created_at', '>=', $start); + } else if ($end) { + $q->where('created_at', '<=', $end); + } + })->date()->width(6); + }); + + $grid->disableRowSelector(); + $grid->disableCreateButton(); + $grid->disableDeleteButton(); + + $user = Admin::user(); + $grid->actions(function (Actions $actions) use ($user) { + $row = $actions->row; + $actions->edit($row->refund_status === RefundStatus::Apply && $user->can('dcat.admin.order_refunds.edit')); + }); + }); + } + + protected function detail($id) + { + return Show::make($id, OrderRefund::with(['order']), function (Show $show) { + $show->field('order.sn'); + $show->field('sn'); + $show->field('refund_reason'); + $show->field('refund_money'); + $show->field('refund_way')->as(fn() => $this->refund_way->label())->unescape(); + $show->field('refund_status')->as(fn() => $this->refund_status->label())->unescape(); + $show->field('finish_at'); + $show->field('created_at'); + $show->field('refund_data')->json(); + + $show->disableDeleteButton(); + $show->disableEditButton(); + }); + } + + protected function form() + { + return Form::make(OrderRefund::with(['order']), function (Form $form) { + $form->display('order.sn'); + $form->text('sn')->required(); + $form->select('refund_way')->options(RefundWay::options())->required(); + $form->currency('refund_money')->symbol('¥')->required(); + $form->text('refund_reason')->required(); + + $form->disableEditingCheck(); + $form->disableCreatingCheck(); + $form->disableViewCheck(); + + $form->disableViewButton(); + $form->disableDeleteButton(); + }); + } +} diff --git a/src/Http/Api/OrderController.php b/src/Http/Api/OrderController.php index 68fd06e..49eddbc 100644 --- a/src/Http/Api/OrderController.php +++ b/src/Http/Api/OrderController.php @@ -42,8 +42,6 @@ class OrderController extends Controller $request->validate([ 'goods' => [Rule::requiredIf(!$request->filled('cart_id')), 'array'], 'cart_id' => ['array'], - 'address' => [Rule::requiredIf(!$request->filled('address_id')), 'array'], - 'address_id' => ['numeric'] ]); $user = auth('api')->user(); @@ -107,8 +105,7 @@ class OrderController extends Controller $request->validate([ 'goods' => [Rule::requiredIf(!$request->filled('cart_id')), 'array'], - 'cart_id' => ['array'], - 'address' => [Rule::requiredIf(!$request->filled('address_id')), 'array'], + 'cart_id' => ['array'] ]); $store = OrderStore::init()->user($user)->remarks($request->input('remarks')); @@ -118,8 +115,8 @@ class OrderController extends Controller } $store->goods($goods); - $address = $this->getAddress(); - $store->ship($request->input('ship_way', ShipWay::None->value), $address); + $shipWay = $request->input('ship_way', ShipWay::None->value); + $store->ship($shipWay, $shipWay === ShipWay::Express ? $this->getAddress() : null); $store->score($request->input('score', 0)); diff --git a/src/Models/Order.php b/src/Models/Order.php index 34a089e..ea7f097 100644 --- a/src/Models/Order.php +++ b/src/Models/Order.php @@ -25,7 +25,8 @@ class Order extends Model 'ship_address', 'ship_at', 'ship_money', 'ship_status', 'ship_way', 'receive_at', 'score_discount_amount', 'score_discount_money', 'score_discount_ratio', 'pay_at', 'pay_money', 'pay_no', 'pay_status', 'pay_way', 'discount_price', - 'auto_close_at', 'extra', 'is_closed', 'refund_status', 'remarks', 'review_at', 'user_remarks' + 'refund_status', 'refund_money', + 'auto_close_at', 'extra', 'is_closed', 'remarks', 'review_at', 'user_remarks' ]; protected $casts = [ @@ -67,6 +68,11 @@ class Order extends Model return $this->hasMany(OrderOption::class, 'order_id'); } + public function refunds() + { + return $this->hasMany(OrderRefund::class, 'order_id'); + } + public function status() { if ($this->is_closed) { @@ -82,6 +88,12 @@ class Order extends Model return OrderStatus::PayFail; } if ($this->pay_status === PayStatus::Success) { + if ($this->refund_status === RefundStatus::Apply || $this->refund_status === RefundStatus::Processing) { + return OrderStatus::Refunding; + } + if ($this->refund_status === RefundStatus::Success) { + return $this->refund_money >= $this->pay_money ? OrderStatus::RefundFull : OrderStatus::Refund; + } if ($this->ship_status === ShipStatus::Processing) { return OrderStatus::Send; } @@ -203,6 +215,35 @@ class Order extends Model return true; } + public function canRefund($throw = false) + { + if ($this->pay_status === PayStatus::None || $this->pay_status === PayStatus::Processing) { + if ($throw) { + throw new OrderException('订单未支付, 无法申请退款'); + } + return false; + } + if ($this->pay_status === PayStatus::Refund) { + if ($throw) { + throw new OrderException('订单已经退款'); + } + return false; + } + if ($this->refund_status === RefundStatus::Apply || $this->refund_status === RefundStatus::Processing) { + if ($throw) { + throw new OrderException('退款申请处理中'); + } + return false; + } + if ($this->refund_money >= $this->pay_money) { + if ($throw) { + throw new OrderException('订单已经全额退款, 不能再申请退款'); + } + return false; + } + return true; + } + public function scopeSort($q) { return $q->orderBy('created_at', 'desc'); diff --git a/src/Models/OrderRefund.php b/src/Models/OrderRefund.php new file mode 100644 index 0000000..29032ec --- /dev/null +++ b/src/Models/OrderRefund.php @@ -0,0 +1,37 @@ + 'json', + 'refund_status' => RefundStatus::class, + 'refund_way' => RefundWay::class, + ]; + + protected $dates = ['finish_at']; + + protected $attributes = [ + 'refund_status' => RefundStatus::None, + ]; + + public function order() + { + return $this->belongsTo(Order::class, 'order_id'); + } + + public function scopeSort($q) + { + return $q->orderBy('created_at', 'desc'); + } +} diff --git a/src/OrderServiceProvider.php b/src/OrderServiceProvider.php index ebfe652..461bdee 100644 --- a/src/OrderServiceProvider.php +++ b/src/OrderServiceProvider.php @@ -52,7 +52,8 @@ class OrderServiceProvider extends ServiceProvider // ]], $menu->add([ ['id' => 1, 'parent_id' => 0, 'title' => '订单模块', 'icon' => 'feather icon-book', 'uri' => '', 'permission' => ['orders']], - ['id' => 2, 'parent_id' => 1, 'title' => '订单管理', 'icon' => '', 'uri' => '/orders', 'permission' => 'orders.index'] + ['id' => 2, 'parent_id' => 1, 'title' => '订单管理', 'icon' => '', 'uri' => '/orders', 'permission' => 'orders.index'], + ['id' => 3, 'parent_id' => 1, 'title' => '退款申请', 'icon' => '', 'uri' => '/order-refunds', 'permission' => 'order_refunds.index'], ]); }); } diff --git a/src/RefundService.php b/src/RefundService.php new file mode 100644 index 0000000..91ee461 --- /dev/null +++ b/src/RefundService.php @@ -0,0 +1,133 @@ +canRefund(true); + $money = data_get($params, 'refund_money', $order->pay_money - $order->refund_money); + if ($money + $order->refund_money > $order->pay_money) { + throw new OrderException('退款金额超过支付金额'); + } + if ($order->refunds()->whereIn('refund_status', [RefundStatus::Apply, RefundStatus::Processing])->exists()) { + throw new OrderException('退款申请处理中, 请勿重复申请'); + } + $orderService = OrderService::make(); + $sn = $orderService->generateSn(); + $way = data_get($params, 'way', RefundWay::Origin->value); + + $info = $order->refunds()->create([ + 'sn' => $sn, + 'refund_status' => RefundStatus::Apply, + 'refund_way' => $way, + 'refund_money' => $money, + 'refund_reason' => data_get($params, 'refund_reason'), + ]); + + $order->update(['refund_status' => RefundStatus::Apply]); + + return $info; + } + + /** + * 处理退款申请 + * + * @param Order $order + * @param bool $status 同意/不同意 + * @param string $reason 不同意的原因 + * @throws OrderException + */ + public function handle(OrderRefund $info, bool $status, $reason = '') + { + if ($info->refund_status !== RefundStatus::Apply && $info->refund_status !== RefundStatus::Fail) { + throw new OrderException('退款已经处理'); + } + + $info->refund_status = RefundStatus::Processing; + if ($status) { + if ($info->refund_way === RefundWay::Origin) { + $orderService = OrderService::make(); + $result = $orderService->refundPay($info->order, $info->refund_money, $info->refund_reason, $info->sn); + if ($result) { + $this->success($info, $result); + } else { + $this->fail($info, $result['message']); + } + } + else if ($info->refund_way === RefundWay::Offline) { + $this->success($info); + } + } else { + $this->fail($info, $reason); + } + } + + public function success(OrderRefund $info, array $params = null) + { + if ($info->refund_status !== RefundStatus::Processing) { + throw new OrderException('退款已经处理'); + } + + $info->update([ + 'refund_status' => RefundStatus::Success, + 'finish_at' => data_get($params, 'finish_at', now()), + 'refund_data' => $params + ]); + + $order = $info->order; + if ($order) { + $order->increment('refund_money', $info->refund_money, ['refund_status' => RefundStatus::Success]); + } + } + + public function fail(OrderRefund $info, $reason = '') + { + if ($info->refund_status !== RefundStatus::Processing) { + throw new OrderException('退款已经处理'); + } + + $info->update([ + 'refund_status' => RefundStatus::Fail, + 'finish_at' => now(), + 'refund_data' => ['fail_reason' => $reason], + ]); + + $order = $info->order; + if ($order) { + $order->update(['refund_status' => RefundStatus::Fail]); + } + } + + public function cancel(OrderRefund $info) + { + $info->update([ + 'refund_status' => RefundStatus::Cancel, + 'finish_at' => now(), + ]); + $order = $info->order; + if ($order) { + $order->update(['refund_status' => RefundStatus::Cancel]); + } + } +} \ No newline at end of file