6
0
Fork 0

线下订单

base
Jing Li 2023-11-03 10:29:03 +08:00
parent dddef607d3
commit 7f4c44aa5e
20 changed files with 994 additions and 2 deletions

View File

@ -0,0 +1,192 @@
<?php
namespace App\Admin\Controllers;
use App\Admin\Repositories\OfflineOrder as OfflineOrderRepository;
use App\Admin\Widgets\InfoBox;
use App\Enums\OfflineOrderStatus;
use App\Enums\PayWay;
use App\Models\OfflineOrder;
use App\Models\OfflineOrderItem;
use App\Models\Order;
use App\Models\Store\Store;
use App\Models\UserInfo;
use Dcat\Admin\Admin;
use Dcat\Admin\Grid;
use Dcat\Admin\Grid\Column;
use Dcat\Admin\Http\Controllers\AdminController;
use Dcat\Admin\Layout\Row;
use Dcat\Admin\Show;
use Dcat\Admin\Widgets\Box;
class OfflineOrderController extends AdminController
{
protected function grid()
{
Admin::style(
<<<CSS
.card-header {
margin-top: 1.5rem !important;
margin-bottom: -1rem !important;
}
CSS
);
$grid = new Grid(OfflineOrderRepository::with(['user', 'userInfo', 'store']));
$grid->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 <<<HTML
<img src="{$avatar}" width="45" />
<span class="label bg-danger">{$nickname}</span>
<span class="label bg-success">{$phone}</span>
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));
});
};
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Admin\Repositories;
use App\Models\OfflineOrder as Model;
use Dcat\Admin\Repositories\EloquentRepository;
class OfflineOrder extends EloquentRepository
{
protected $eloquentClass = Model::class;
}

View File

@ -186,6 +186,10 @@ Route::group([
'only' => ['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');

View File

@ -0,0 +1,76 @@
<?php
namespace App\Endpoint\Api\Http\Controllers;
use App\Endpoint\Api\Http\Controllers\Controller;
use App\Enums\PayWay;
use App\Models\OfflineOrder;
use App\Models\OfflineOrderPreview;
use App\Services\OfflineOrderService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Throwable;
class OfflineOrderController extends Controller
{
/**
* 确认订单
*/
public function check(Request $request, OfflineOrderService $offlineOrderService)
{
$request->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);
});
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Endpoint\Api\Http\Controllers;
use App\Endpoint\Api\Http\Controllers\Controller;
use App\Exceptions\BizException;
use App\Models\OfflineOrderPreview;
use App\Models\Store\Store;
use EasyWeChat\Factory;
use EasyWeChat\Kernel\Http\StreamResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class OfflineOrderPreviewController extends Controller
{
public function store(Request $request)
{
$request->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,
]);
}
}

View File

@ -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']);
});
// 微信小程序

View File

@ -0,0 +1,42 @@
<?php
namespace App\Enums;
use Dcat\Admin\Admin;
enum OfflineOrderStatus: int {
case Pending = 0;
case Paid = 1;
case Revoked = 10;
public function label()
{
$background = Admin::color()->get($this->color());
return "<span class='label' style='background: $background;'>{$this->text()}</span>";
}
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 => '已取消',
];
}
}

View File

@ -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 '<i class="fa fa-circle" style="font-size: 13px;color: '.$color.'"></i>&nbsp;'.$this->text() ?? 'Unknown';
}
public function color()
{
return match ($this) {

View File

@ -0,0 +1,92 @@
<?php
namespace App\Models;
use App\Enums\OfflineOrderStatus;
use App\Enums\PayWay;
use App\Models\Store\Store;
use Dcat\Admin\Traits\HasDateTimeFormatter;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OfflineOrder extends Model
{
use HasFactory, HasDateTimeFormatter;
protected $attributes = [
'discount_reduction_amount' => 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;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Dcat\Admin\Traits\HasDateTimeFormatter;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OfflineOrderItem extends Model
{
use HasFactory, HasDateTimeFormatter;
protected $attributes = [
'discount_reduction_amount' => 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');
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use App\Models\Store\Store;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OfflineOrderPreview extends Model
{
use HasFactory;
protected $casts = [
'payload' => 'json',
];
protected $fillable = [
'store_id', 'staff_id', 'payload', 'qrcode',
];
public function store()
{
return $this->belongsTo(Store::class);
}
}

View File

@ -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();

View File

@ -0,0 +1,238 @@
<?php
namespace App\Services;
use App\Enums\OfflineOrderStatus;
use App\Enums\PayWay;
use App\Enums\PointLogAction;
use App\Enums\SocialiteType;
use App\Enums\WxpayTradeType;
use App\Exceptions\BizException;
use App\Models\OfflineOrder;
use App\Models\OfflineOrderItem;
use App\Models\OfflineOrderPreview;
use App\Models\OfflineProductCategory;
use App\Models\SocialiteUser;
use App\Models\User;
use App\Services\Payment\WxpayService;
class OfflineOrderService
{
public function create(User $user, OfflineOrderPreview $orderPreview, int $points = 0): OfflineOrder
{
$items = $this->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];
}
}

View File

@ -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]);
}

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOfflineOrderPreviewsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('offline_order_previews', function (Blueprint $table) {
$table->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');
}
}

View File

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOfflineOrdersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('offline_orders', function (Blueprint $table) {
$table->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');
}
}

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOfflineOrderItemsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('offline_order_items', function (Blueprint $table) {
$table->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');
}
}

View File

@ -368,6 +368,11 @@ class AdminMenuSeeder extends Seeder
'icon' => '',
'uri' => 'offline-product-categories',
],
[
'title' => '线下订单',
'icon' => '',
'uri' => 'offline-orders',
],
],
],
];

View File

@ -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();

View File

@ -0,0 +1,27 @@
<?php
return [
'labels' => [
'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' => [
],
];