6
0
Fork 0

线下订单

base
panliang 2026-06-28 20:13:29 +08:00
parent 93e30b0496
commit 2471bec5e1
24 changed files with 681 additions and 36 deletions

View File

@ -120,7 +120,7 @@ class CouponController extends AdminController
return $this->use_end_at?->format('Y-m-d H:i:s'); return $this->use_end_at?->format('Y-m-d H:i:s');
}); });
$grid->column('is_use', '是否使用')->display(function () { $grid->column('is_use', '是否使用')->display(function () {
return $this->is_use ? '已使用' : '未使用'; return $this->is_use ? $this->use_at?->format('Y-m-d H:i:s') : '未使用';
}); });
$grid->disableActions(); $grid->disableActions();

View File

@ -46,29 +46,11 @@ class OfflineOrderController extends AdminController
}); });
$grid->column('sn')->copyable(); $grid->column('sn')->copyable();
$grid->column('user_id')->display(function () { $grid->column('user_id')->display(function () {
$nickname = $this->userInfo?->nickname ?? '---'; return $this->user?->phone;
$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('store_id')->display(fn() => $this->store?->title);
$grid->column('staff_id')->display(function () { $grid->column('staff_id')->display(function () {
if (is_null($this->staff_id)) { return $this->staff?->phone;
return '';
}
$nickname = $this->staffInfo?->nickname ?? '---';
$avatar = $this->staffInfo?->avatar ?? 'https://via.placeholder.com/45x45.png';
$phone = $this->staff?->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('items')->display(function ($items) { $grid->column('items')->display(function ($items) {
return $items->map(function ($item) { return $items->map(function ($item) {
@ -85,6 +67,7 @@ class OfflineOrderController extends AdminController
$grid->column('products_total_amount')->display(fn($v) => bcdiv($v, 100, 2))->prepend('¥'); $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('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('points_deduction_amount')->display(fn($v) => bcdiv($v, 100, 2))->prepend('¥');
$grid->column('coupon_discount_amount')->display(fn($v) => bcdiv($v, 100, 2))->prepend('¥');
$grid->column('payment_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->dot()); $grid->column('status')->display(fn($v) => $v->dot());
$grid->column('payment_method')->display(fn($v) => $v?->dot()); $grid->column('payment_method')->display(fn($v) => $v?->dot());
@ -175,7 +158,7 @@ class OfflineOrderController extends AdminController
{ {
return function (Row $row) use ($id) { return function (Row $row) use ($id) {
$row->column(5, function ($column) use ($id) { $row->column(5, function ($column) use ($id) {
$builder = OfflineOrderRepository::with(['user', 'userInfo', 'store', 'staff', 'staffInfo']); $builder = OfflineOrderRepository::with(['user', 'userInfo', 'store', 'staff', 'staffInfo', 'coupon']);
$column->row(Show::make($id, $builder, function (Show $show) { $column->row(Show::make($id, $builder, function (Show $show) {
$show->row(function (Show\Row $show) { $show->row(function (Show\Row $show) {
$show->width(6)->field('sn'); $show->width(6)->field('sn');
@ -195,6 +178,9 @@ class OfflineOrderController extends AdminController
$show->field('points_deduction_amount') $show->field('points_deduction_amount')
->as(fn ($v) => bcdiv($v, 100, 2)) ->as(fn ($v) => bcdiv($v, 100, 2))
->prepend('-¥'); ->prepend('-¥');
$show->field('coupon_discount_amount')
->as(fn ($v) => bcdiv($v, 100, 2) . '('.$this->coupon?->coupon_name.')')
->prepend('-¥');
$show->field('payment_amount') $show->field('payment_amount')
->as(fn ($v) => bcdiv($v, 100, 2)) ->as(fn ($v) => bcdiv($v, 100, 2))
->prepend('¥'); ->prepend('¥');

View File

@ -7,9 +7,11 @@ use App\Models\OfflineOrder;
use App\Models\OfflineOrderItem; use App\Models\OfflineOrderItem;
use App\Models\OfflineProductCategory; use App\Models\OfflineProductCategory;
use App\Models\Store\Store; use App\Models\Store\Store;
use App\Models\User;
use Dcat\Admin\Grid; use Dcat\Admin\Grid;
use Dcat\Admin\Http\Controllers\AdminController; use Dcat\Admin\Http\Controllers\AdminController;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Dcat\Admin\Layout\Content;
class OfflineOrderItemStatisticController extends AdminController class OfflineOrderItemStatisticController extends AdminController
{ {
@ -109,4 +111,50 @@ class OfflineOrderItemStatisticController extends AdminController
return $grid; return $grid;
} }
/**
* 员工业绩统计
*/
public function staff(Content $content)
{
$table = (new OfflineOrder())->getTable();
$stores = Store::pluck('title', 'id');
$builder = OfflineOrder::query()->with(['staff', 'store'])->select([
"{$table}.staff_id",
"{$table}.store_id",
DB::raw("count(1) as count"),
DB::raw("sum({$table}.products_total_amount) as products_total_amount"),
DB::raw("sum({$table}.discount_reduction_amount) as discount_reduction_amount"),
DB::raw("sum({$table}.points_deduction_amount) as points_deduction_amount"),
DB::raw("sum({$table}.coupon_discount_amount) as coupon_discount_amount"),
DB::raw("sum({$table}.payment_amount) as payment_amount"),
])->where('status', OfflineOrderStatus::Paid)->groupBy(["staff_id", "store_id"]);
$grid = Grid::make($builder);
$grid->filter(function (Grid\Filter $filter) use ($stores) {
$filter->panel();
$filter->equal('store_id')->select($stores)->width(4);
$filter->between('created_at', "下单时间")->datetime()->width(4);
});
$grid->column("staff_id", "员工")->display(fn () => $this->staff?->phone);
$grid->column("store_id", "门店")->display(fn () => $this->store?->title);
$grid->column("count", "订单数");
$grid->column('products_total_amount', "订单总额")->display(fn ($v) => bcdiv($v, 100, 2));
$grid->column('discount_reduction_amount', "折扣优惠")->display(fn ($v) => bcdiv($v, 100, 2));
$grid->column('points_deduction_amount', "积分抵扣")->display(fn ($v) => bcdiv($v, 100, 2));
$grid->column('coupon_discount_amount', "优惠券抵扣")->display(fn ($v) => bcdiv($v, 100, 2));
$grid->column('payment_amount', "实付金额")->display(fn ($v) => bcdiv($v, 100, 2));
$grid->disablePagination();
$grid->disableFilterButton();
$grid->disableActions();
return $content
->translation($this->translation())
->title("员工业绩统计")
->description($this->description()['index'] ?? trans('admin.list'))
->body($grid);
}
} }

View File

@ -191,6 +191,7 @@ Route::group([
])->names('offline_orders'); ])->names('offline_orders');
$router->get('offline-order-item-statistics', 'OfflineOrderItemStatisticController@index')->name('offline_order_item_statistics.index'); $router->get('offline-order-item-statistics', 'OfflineOrderItemStatisticController@index')->name('offline_order_item_statistics.index');
$router->get('offline-order-staff-statistics', 'OfflineOrderItemStatisticController@staff')->name('offline_order_item_statistics.staff');
/** 调试接口 **/ /** 调试接口 **/
// $router->get('test', 'HomeController@test'); // $router->get('test', 'HomeController@test');

View File

@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use App\Models\OfflineOrder;
use App\Services\OfflineOrderService;
use Illuminate\Console\Command;
class OfflineOrderShip extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'offline-order:ship {id}';
/**
* The console command description.
*
* @var string
*/
protected $description = '线下订单发货';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$id = $this->argument('id');
$order = OfflineOrder::findOrFail($id);
OfflineOrderService::make()->orderShip($order);
return 0;
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Console\Commands;
use App\Enums\OfflineOrderStatus;
use App\Models\OfflineOrder;
use App\Services\OfflineOrderService;
use Illuminate\Console\Command;
class OfflineOrderSuccess extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'offline-order:success {id}';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$id = $this->argument("id");
$order = OfflineOrder::findOrFail($id);
if ($order->status !== OfflineOrderStatus::Pending) {
$this->warn("订单已处理: " . $order->status->text());
}
OfflineOrderService::make()->paySuccess($order, [
"payment_sn" => "000",
"payment_method" => "wxpay_mp",
"payment_time" => now(),
"out_trade_no" => "000"
]);
return 0;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Endpoint\Api\Filters;
use EloquentFilter\ModelFilter;
class OfflineOrderFilter extends ModelFilter
{
/**
* Related Models that have ModelFilters as well as the method on the ModelFilter
* As [relationMethod => [input_key1, input_key2]].
*
* @var array
*/
public $relations = [];
public function staff(string $value)
{
$this->where('staff_id', $value);
}
public function user(string $value)
{
$this->where('user_id', $value);
}
public function createdStart(string $value)
{
$this->where('created_at', '>=', $value);
}
public function createdEnd(string $value)
{
$this->where('created_at', '<=', $value);
}
}

View File

@ -9,6 +9,9 @@ class UserCouponFilter extends ModelFilter
public function status($status) public function status($status)
{ {
switch ($status) { switch ($status) {
case 'available':
$this->onlyAvailable();
break;
case 'unuse': case 'unuse':
$this->onlyUnuse(); $this->onlyUnuse();
break; break;

View File

@ -3,7 +3,9 @@
namespace App\Endpoint\Api\Http\Controllers; namespace App\Endpoint\Api\Http\Controllers;
use App\Endpoint\Api\Http\Controllers\Controller; use App\Endpoint\Api\Http\Controllers\Controller;
use App\Endpoint\Api\Http\Resources\OfflineOrderResource;
use App\Enums\PayWay; use App\Enums\PayWay;
use App\Exceptions\BizException;
use App\Models\OfflineOrder; use App\Models\OfflineOrder;
use App\Models\OfflineOrderPreview; use App\Models\OfflineOrderPreview;
use App\Services\OfflineOrderService; use App\Services\OfflineOrderService;
@ -13,6 +15,32 @@ use Throwable;
class OfflineOrderController extends Controller class OfflineOrderController extends Controller
{ {
public function index(Request $request)
{
$user = $request->user();
$query = OfflineOrder::query()->with(['store'])->filter($request->all())->orderBy('created_at', 'desc');
// 员工身份, 查询我创建的订单
if ($request->input('use_staff')) {
$query->where('staff_id', $user->id);
}
// 用户身份, 查询我支付的订单
if ($request->input('use_user')) {
$query->where('user_id', $user->id);
}
$list = $query->paginate($request->input('per_page'));
return OfflineOrderResource::collection($list);
}
public function show(string $id)
{
$info = OfflineOrder::find($id);
if (!$info) {
throw new BizException("记录不存在");
}
return OfflineOrderResource::make($info);
}
/** /**
* 确认订单 * 确认订单
*/ */
@ -24,7 +52,7 @@ class OfflineOrderController extends Controller
$preview = OfflineOrderPreview::findOrFail($request->input('order_preview_id')); $preview = OfflineOrderPreview::findOrFail($request->input('order_preview_id'));
return $offlineOrderService->check($request->user(), $preview->payload['items']); return $offlineOrderService->check($request->user(), $preview);
} }
/** /**
@ -46,7 +74,7 @@ class OfflineOrderController extends Controller
$request->user(), $request->user(),
$preview, $preview,
bcmul($request->input('points', 0), 100), bcmul($request->input('points', 0), 100),
['user_remark' => $request->input('user_remark')] ['user_remark' => $request->input('user_remark'), 'coupon_id' => $preview->coupon_id]
); );
DB::commit(); DB::commit();

View File

@ -3,9 +3,11 @@
namespace App\Endpoint\Api\Http\Controllers; namespace App\Endpoint\Api\Http\Controllers;
use App\Endpoint\Api\Http\Controllers\Controller; use App\Endpoint\Api\Http\Controllers\Controller;
use App\Endpoint\Api\Http\Resources\OfflineOrderPreviewResource;
use App\Exceptions\BizException; use App\Exceptions\BizException;
use App\Models\OfflineOrderPreview; use App\Models\OfflineOrderPreview;
use App\Models\Store\Store; use App\Models\Store\Store;
use App\Models\UserCoupon;
use EasyWeChat\Factory; use EasyWeChat\Factory;
use EasyWeChat\Kernel\Http\StreamResponse; use EasyWeChat\Kernel\Http\StreamResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -13,6 +15,21 @@ use Illuminate\Support\Facades\Storage;
class OfflineOrderPreviewController extends Controller class OfflineOrderPreviewController extends Controller
{ {
public function index(Request $request)
{
$user = $request->user();
$query = OfflineOrderPreview::query()->with(['store', 'coupon', 'order'])->where('staff_id', $user->id)->orderBy('created_at', 'desc');
$list = $query->paginate($request->input('per_page'));
return OfflineOrderPreviewResource::collection($list);
}
public function detail(string $id)
{
$info = OfflineOrderPreview::with(['store', 'coupon', 'order.user'])->findOrFail($id);
return OfflineOrderPreviewResource::make($info);
}
public function store(Request $request) public function store(Request $request)
{ {
$request->validate([ $request->validate([
@ -30,12 +47,38 @@ class OfflineOrderPreviewController extends Controller
} }
$store = Store::findOrFail($request->input('store_id')); $store = Store::findOrFail($request->input('store_id'));
$couponId = $request->input('coupon_id');
$userId = $request->input('user_id');
$coupon = null;
$couponDiscount = 0;
if ($couponId) {
$coupon = UserCoupon::query()->onlyAvailable()->find($couponId);
if (!$coupon) {
throw new BizException('优惠券不可用');
}
$userId = $coupon->user_id;
}
$items = $request->input('items');
$totalAmount = 0;
$paymentAmount = 0;
$discountAmount = 0;
foreach($items as $item) {
$totalAmount += $item['products_total_amount'];
$paymentAmount += bcmul($item['products_total_amount'], $item['discount'] / 10, 2);
}
$discountAmount = bcsub($totalAmount, $paymentAmount, 2);
if ($coupon) {
$couponDiscount = bcdiv($coupon->getDiscountAmount($paymentAmount), 100, 2);
$paymentAmount = bcsub($paymentAmount, $couponDiscount, 2);
}
$preview = OfflineOrderPreview::create([ $preview = OfflineOrderPreview::create([
'store_id' => $store->id, 'store_id' => $store->id,
'staff_id' => $user->id, 'staff_id' => $user->id,
'payload' => ['items' => $request->input('items')], 'payload' => ['items' => $items, 'total_amount' => $totalAmount, 'discount_amount' => $discountAmount, 'coupon_discount' => $couponDiscount, 'payment_amount' => $paymentAmount],
'remark' => $request->input('remark') 'remark' => $request->input('remark'),
'user_id' => $userId,
'coupon_id' => $couponId,
]); ]);
if (config('app.env') != "local") { if (config('app.env') != "local") {

View File

@ -3,7 +3,10 @@
namespace App\Endpoint\Api\Http\Controllers; namespace App\Endpoint\Api\Http\Controllers;
use App\Endpoint\Api\Http\Resources\UserCouponResource; use App\Endpoint\Api\Http\Resources\UserCouponResource;
use App\Exceptions\BizException;
use App\Helpers\Paginator as PaginatorHelper; use App\Helpers\Paginator as PaginatorHelper;
use App\Models\User;
use App\Models\UserCoupon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class UserCouponController extends Controller class UserCouponController extends Controller
@ -17,11 +20,73 @@ class UserCouponController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
$user = $request->user(); $user = $request->user();
$query = $user->coupons();
$coupons = $user->coupons() $coupons = $query->filter($request->all())->simplePaginate(PaginatorHelper::resolvePerPage('per_page', 20, 50));
->filter($request->all())
->simplePaginate(PaginatorHelper::resolvePerPage('per_page', 20, 50));
return UserCouponResource::collection($coupons); return UserCouponResource::collection($coupons);
} }
/**
* 查询所有用户的优惠券
*/
public function list(Request $request)
{
$user = $request->user();
// 员工身份
if (!$user->userInfo->is_company) {
throw new BizException("非员工身份");
}
$query = UserCoupon::query();
if ($request->input('user_phone')) {
$user = User::where('phone', $request->input('user_phone'))->first();
if (!$user) {
throw new BizException("手机号不存在");
}
$query->where('user_id', $user->id);
}
$coupons = $query->filter($request->all())->simplePaginate(PaginatorHelper::resolvePerPage('per_page', 20, 50));
return UserCouponResource::collection($coupons);
}
/**
* 计算优惠券抵扣金额
*/
public function discount(Request $request)
{
$request->validate([
'id' => 'required'
], [
'id.required' => '优惠券id必填'
]);
$id = $request->input('id');
$coupon = UserCoupon::query()->onlyAvailable()->findOrFail($id);
$discount = $coupon->getDiscountAmount($request->input('total', 0));
return response()->json(['discount' => bcdiv($discount, 100, 2)]);
}
/**
* 直接核销优惠券
*/
public function check(Request $request)
{
$user = $request->user();
// 员工身份
if (!$user->userInfo->is_company) {
throw new BizException("非员工身份");
}
$request->validate([
'id' => 'requreid'
], [
'id.required' => '优惠券id必填'
]);
$id = $request->input('id');
$coupon = UserCoupon::query()->onlyAvailable()->findOrFail($id);
$coupon->markAsUse();
return response()->json([]);
}
} }

View File

@ -0,0 +1,26 @@
<?php
namespace App\Endpoint\Api\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class OfflineOrderItemResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
return [
'id' => $this->id,
'order_id' => $this->order_id,
'product_category_id' => $this->product_category_id,
'product_category' => OfflineProductCategoryResource::make($this->whenLoaded('productCategory')),
'products_total_amount' => $this->products_total_amount,
'discount_reduction_amount' => $this->discount_reduction_amount,
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Endpoint\Api\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class OfflineOrderPreviewResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
return [
'id' => $this->id,
'store_id' => $this->store_id,
'store' => StoreResource::make($this->whenLoaded('store')),
'payload' => $this->payload,
'remark' => $this->remark,
'coupon_id' => $this->coupon_id,
'coupon' => UserCouponResource::make($this->whenLoaded('coupon')),
'order' => OfflineOrderResource::make($this->whenLoaded('order')),
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'orders' => OfflineOrderResource::collection($this->whenLoaded('orders')),
'qrcode' => $this->qrcode,
];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Endpoint\Api\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class OfflineOrderResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
return [
'id' => $this->id,
'user_id' => $this->user_id,
'store_id' => $this->store_id,
'store' => StoreResource::make($this->whenLoaded('store')),
'user' => UserResource::make($this->whenLoaded('user')),
'staff_id' => $this->staff_id,
'sn' => $this->sn,
'products_total_amount' => bcdiv($this->products_total_amount, 100, 2),
'discount_reduction_amount' => bcdiv($this->discount_reduction_amount, 100, 2),
'points_deduction_amount' => bcdiv($this->points_deduction_amount, 100, 2),
'coupon_discount_amount' => bcdiv($this->coupon_discount_amount, 100, 2),
'payment_amount' => bcdiv($this->payment_amount, 100, 2),
'status' => $this->status,
'status_text' => $this->status->text(),
'payment_time' => $this->payment_time?->format('Y-m-d H:i:s'),
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'payment_sn' => $this->payment_sn,
'user_remark' => $this->user_remark,
'staff_remark' => $this->staff_remark,
'coupon_id' => $this->coupon_id,
'items' => OfflineOrderItemResource::collection($this->whenLoaded('items')),
];
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Endpoint\Api\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'phone' => $this->phone,
];
}
}

View File

@ -173,6 +173,9 @@ Route::group([
// 我的优惠券 // 我的优惠券
Route::get('coupons', [UserCouponController::class, 'index']); Route::get('coupons', [UserCouponController::class, 'index']);
// 查询优惠券记录
Route::get('coupon-list', [UserCouponController::class, 'list']);
Route::post('coupons/discount', [UserCouponController::class, 'discount']);
//签到 //签到
Route::get('click', [ClickController::class, 'index']); Route::get('click', [ClickController::class, 'index']);
@ -222,11 +225,15 @@ Route::group([
Route::post('draw-activities/{draw_activity}/draw', [App\Endpoint\Api\Http\Controllers\DrawActivityController::class, 'draw']); 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::put('draw-activities/{draw_activity}/logs/{log}', [App\Endpoint\Api\Http\Controllers\DrawLogController::class, 'update']);
Route::get('offline-order-previews', [App\Endpoint\Api\Http\Controllers\OfflineOrderPreviewController::class, 'index']);
Route::get('offline-order-previews/{id}/detail', [App\Endpoint\Api\Http\Controllers\OfflineOrderPreviewController::class, 'detail']);
Route::get('offline-order-previews/{offline_order_preview}', [App\Endpoint\Api\Http\Controllers\OfflineOrderPreviewController::class, 'show']); 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-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/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', [App\Endpoint\Api\Http\Controllers\OfflineOrderController::class, 'store']);
Route::post('offline-orders/{offline_order}/pay', [App\Endpoint\Api\Http\Controllers\OfflineOrderController::class, 'pay']); Route::post('offline-orders/{offline_order}/pay', [App\Endpoint\Api\Http\Controllers\OfflineOrderController::class, 'pay']);
Route::get('offline-orders', [App\Endpoint\Api\Http\Controllers\OfflineOrderController::class, 'index']);
Route::get('offline-orders/{id}', [App\Endpoint\Api\Http\Controllers\OfflineOrderController::class, 'show']);
}); });
// 微信小程序 // 微信小程序

View File

@ -12,8 +12,14 @@ class Coupon extends Model
use HasFactory; use HasFactory;
use HasDateTimeFormatter; use HasDateTimeFormatter;
public const TYPE_AMOUNT = 1; // 抵扣券 /**
public const TYPE_DISCOUNT = 2; // 折扣券 * 抵扣券: 直接抵扣金额
*/
public const TYPE_AMOUNT = 1;
/**
* 折扣券: 计算折扣
*/
public const TYPE_DISCOUNT = 2;
protected $casts = [ protected $casts = [
'amount' => Price::class, 'amount' => Price::class,

View File

@ -6,12 +6,13 @@ use App\Enums\OfflineOrderStatus;
use App\Enums\PayWay; use App\Enums\PayWay;
use App\Models\Store\Store; use App\Models\Store\Store;
use Dcat\Admin\Traits\HasDateTimeFormatter; use Dcat\Admin\Traits\HasDateTimeFormatter;
use EloquentFilter\Filterable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class OfflineOrder extends Model class OfflineOrder extends Model
{ {
use HasFactory, HasDateTimeFormatter; use HasFactory, HasDateTimeFormatter, Filterable;
protected $attributes = [ protected $attributes = [
'discount_reduction_amount' => 0, 'discount_reduction_amount' => 0,
@ -24,6 +25,7 @@ class OfflineOrder extends Model
'status' => OfflineOrderStatus::class, 'status' => OfflineOrderStatus::class,
'payment_method' => PayWay::class, 'payment_method' => PayWay::class,
'revoked_at' => 'datetime', 'revoked_at' => 'datetime',
'ship_time' => 'datetime',
]; ];
protected $fillable = [ protected $fillable = [
@ -45,6 +47,9 @@ class OfflineOrder extends Model
'orderable_id', 'orderable_id',
'user_remark', 'user_remark',
'staff_remark', 'staff_remark',
'ship_time',
'coupon_id',
'coupon_discount_amount'
]; ];
public function store() public function store()
@ -56,6 +61,10 @@ class OfflineOrder extends Model
{ {
return $this->belongsTo(User::class, 'staff_id'); return $this->belongsTo(User::class, 'staff_id');
} }
public function coupon()
{
return $this->belongsTo(UserCoupon::class, 'coupon_id');
}
public function staffInfo() public function staffInfo()
{ {
@ -82,6 +91,11 @@ class OfflineOrder extends Model
return $this->hasMany(OfflineOrderItem::class, 'order_id'); return $this->hasMany(OfflineOrderItem::class, 'order_id');
} }
public function preview()
{
return $this->morphTo(OfflineOrderPreview::class, 'orderable_type', 'orderable_id', 'id');
}
public function isPending(): bool public function isPending(): bool
{ {
return $this->status === OfflineOrderStatus::Pending; return $this->status === OfflineOrderStatus::Pending;

View File

@ -15,11 +15,26 @@ class OfflineOrderPreview extends Model
]; ];
protected $fillable = [ protected $fillable = [
'store_id', 'staff_id', 'payload', 'qrcode', 'remark' 'store_id', 'staff_id', 'payload', 'qrcode', 'remark', 'user_id', 'coupon_id'
]; ];
public function store() public function store()
{ {
return $this->belongsTo(Store::class); return $this->belongsTo(Store::class);
} }
public function coupon()
{
return $this->belongsTo(UserCoupon::class);
}
public function order()
{
return $this->morphOne(OfflineOrder::class, 'orderable')->orderBy('created_at', 'desc');
}
public function orders()
{
return $this->morphMany(OfflineOrder::class, 'orderable')->orderBy('created_at', 'desc');
}
} }

View File

@ -25,6 +25,7 @@ class UserCoupon extends Model
'is_use' => 'bool', 'is_use' => 'bool',
'use_start_at' => 'datetime', 'use_start_at' => 'datetime',
'use_end_at' => 'datetime', 'use_end_at' => 'datetime',
'use_at' => 'datetime'
]; ];
/** /**
@ -43,6 +44,7 @@ class UserCoupon extends Model
'created_at', 'created_at',
'updated_at', 'updated_at',
'activity_id', 'activity_id',
'use_at'
]; ];
public function user() public function user()
@ -133,6 +135,7 @@ class UserCoupon extends Model
{ {
$this->forceFill([ $this->forceFill([
'is_use' => true, 'is_use' => true,
'use_at' => now(),
])->save(); ])->save();
} }
@ -166,4 +169,22 @@ class UserCoupon extends Model
bcdiv($this->attributes['coupon_threshold'], 100, 2) bcdiv($this->attributes['coupon_threshold'], 100, 2)
); );
} }
/**
* 获取优惠金额
*/
public function getDiscountAmount(int $total)
{
$couponDiscount = 0;
// 优惠券: 直接抵扣
if ($this->coupon_type == Coupon::TYPE_AMOUNT) {
$couponDiscount = $this->coupon_amount;
}
// 优惠券: 折扣
else if ($this->coupon_type == Coupon::TYPE_DISCOUNT) {
$couponDiscount = (int) bcmul($total, bcdiv(100 - $this->coupon_amount, 100, 2));
}
return $couponDiscount;
}
} }

View File

@ -8,18 +8,28 @@ use App\Enums\PointLogAction;
use App\Enums\SocialiteType; use App\Enums\SocialiteType;
use App\Enums\WxpayTradeType; use App\Enums\WxpayTradeType;
use App\Exceptions\BizException; use App\Exceptions\BizException;
use App\Models\Coupon;
use App\Models\OfflineOrder; use App\Models\OfflineOrder;
use App\Models\OfflineOrderItem; use App\Models\OfflineOrderItem;
use App\Models\OfflineOrderPreview; use App\Models\OfflineOrderPreview;
use App\Models\OfflineProductCategory; use App\Models\OfflineProductCategory;
use App\Models\SocialiteUser; use App\Models\SocialiteUser;
use App\Models\User; use App\Models\User;
use App\Models\UserCoupon;
use App\Services\Payment\WxpayService; use App\Services\Payment\WxpayService;
use function EasyWeChat\Kernel\data_get; use function EasyWeChat\Kernel\data_get;
/**
* 线下订单
*/
class OfflineOrderService class OfflineOrderService
{ {
public static function make()
{
return new static();
}
public function create(User $user, OfflineOrderPreview $orderPreview, int $points = 0, $params = []): OfflineOrder public function create(User $user, OfflineOrderPreview $orderPreview, int $points = 0, $params = []): OfflineOrder
{ {
$items = $this->mapItems($orderPreview->payload['items']); $items = $this->mapItems($orderPreview->payload['items']);
@ -32,8 +42,20 @@ class OfflineOrderService
// 积分抵扣金额 // 积分抵扣金额
$pointsDeductionAmount = $points; $pointsDeductionAmount = $points;
$paymentAmount -= $pointsDeductionAmount; $paymentAmount -= $pointsDeductionAmount;
// 优惠券抵扣
$couponDiscount = 0;
$couponId = data_get($params, 'coupon_id');
if ($couponId) {
$coupon = UserCoupon::where('user_id', $user->id)->onlyAvailable()->find($couponId);
if (!$coupon) {
throw new BizException("优惠券不可用");
}
$couponDiscount = $coupon->getDiscountAmount($paymentAmount);
$paymentAmount -= $couponDiscount;
}
if ($paymentAmount < 0) { if ($paymentAmount < 0) {
$paymentAmount = 0; $paymentAmount = 0;
} }
@ -57,6 +79,8 @@ class OfflineOrderService
'orderable_id' => $orderPreview->id, 'orderable_id' => $orderPreview->id,
'user_remark' => data_get($params, 'user_remark'), 'user_remark' => data_get($params, 'user_remark'),
'staff_remark' => $orderPreview->remark, 'staff_remark' => $orderPreview->remark,
'coupon_id' => $couponId,
'coupon_discount_amount' => $couponDiscount,
]); ]);
$this->insertOrderItems($order, $items); $this->insertOrderItems($order, $items);
@ -79,8 +103,12 @@ class OfflineOrderService
return $order; return $order;
} }
public function check(User $user, array $items): array public function check(User $user, OfflineOrderPreview $preview): array
{ {
if ($preview->order && $preview->order->status != OfflineOrderStatus::Pending) {
throw new BizException('订单已支付, 请重新生成');
}
$items = $preview->payload['items'];
$productCategoryIds = collect($items)->pluck('product_category_id'); $productCategoryIds = collect($items)->pluck('product_category_id');
$productCategories = OfflineProductCategory::query() $productCategories = OfflineProductCategory::query()
@ -100,6 +128,20 @@ class OfflineOrderService
$paymentAmount, $paymentAmount,
] = $this->calculateFees($mapItems); ] = $this->calculateFees($mapItems);
// 优惠券
$coupon = null;
$couponDiscount = 0;
if ($preview->coupon_id) {
$coupon = UserCoupon::where('user_id', $user->id)->onlyAvailable()->find($preview->coupon_id);
if ($coupon) {
$couponDiscount = $coupon->getDiscountAmount($paymentAmount);
}
}
$paymentAmount -= $couponDiscount;
if ($paymentAmount < 0) {
$paymentAmount = 0;
}
//--------------------------------------- //---------------------------------------
// 积分当钱花 // 积分当钱花
//--------------------------------------- //---------------------------------------
@ -119,6 +161,8 @@ class OfflineOrderService
'remaining_points' => bcdiv($remainingPoints, 100, 2), // 剩余积分 'remaining_points' => bcdiv($remainingPoints, 100, 2), // 剩余积分
'available_points' => bcdiv($availablePoints, 100, 2), // 可用积分 'available_points' => bcdiv($availablePoints, 100, 2), // 可用积分
'points_discount_amount' => bcdiv($availablePoints, 100, 2), // 积分抵扣金额 'points_discount_amount' => bcdiv($availablePoints, 100, 2), // 积分抵扣金额
// 优惠券
'coupon' => ['id' => $coupon?->id, 'name' => $coupon?->coupon_name, 'discount' => bcdiv($couponDiscount, 100, 2)],
]; ];
} }
@ -266,4 +310,72 @@ class OfflineOrderService
return [$totalProductsTotalAmount, $totalDiscountReductionAmount, $totalPaymentAmount]; return [$totalProductsTotalAmount, $totalDiscountReductionAmount, $totalPaymentAmount];
} }
/**
* 订单支付成功
* @param $data [payment_sn:商户订单号, payment_method:支付方式, payment_time: 支付时间, out_trade_no: 流水号]
*/
public function paySuccess(OfflineOrder $order, array $data)
{
$data['status'] = OfflineOrderStatus::Paid;
$order->update($data);
// 更新优惠券
if ($order->coupon_id) {
$coupon = UserCoupon::find($order->coupon_id);
if ($coupon) {
$coupon->markAsUse();
}
}
if (config('app.env') != "local") {
try {
$this->orderShip($order);
} catch (BizException $e) {
logger()->error("线下订单-自动发货失败: " . $e->getMessage(), ['id' => $order->id, 'sn' => $order->sn]);
}
}
}
/**
* 订单发货
*/
public function orderShip(OfflineOrder $order)
{
$now = now();
if (!$order->isPaid()) {
throw new BizException("订单未支付");
}
$userId = $order->user_id;
$socialite = SocialiteUser::where('user_id', $userId)->where('type', SocialiteType::WechatMiniProgram)->first();
if (!$socialite) {
throw new BizException("未找到用户的openid");
}
$openid = $socialite->socialite_id;
$items = $order->items()->with(['productCategory'])->get();
$desc = [];
foreach ($items as $item) {
$name = data_get($item, "productCategory.name", "定制服务");
array_push($desc, $name);
}
$data = [
"order_key" => [
"order_number_type" => 1,
"out_trade_no" => $order->payment_sn,
],
"logistics_type" => 4,
"delivery_mode" => 1,
"shipping_list" => [
"item_desc" => implode(";", $desc)
],
"upload_time" => $now,
"payer" => [
"openid" => $openid
]
];
WxMpService::make()->uploadShippingInfo($data);
$order->ship_time = $now;
$order->save();
}
} }

View File

@ -78,7 +78,7 @@ class PayService
throw new BizException('订单取消'); throw new BizException('订单取消');
} }
$payable->update([ OfflineOrderService::make()->paySuccess($payable, [
'payment_sn' => $payLog->pay_sn, 'payment_sn' => $payLog->pay_sn,
'payment_method' => $payLog->pay_way, 'payment_method' => $payLog->pay_way,
'payment_time' => $payLog->pay_at, 'payment_time' => $payLog->pay_at,

View File

@ -0,0 +1,42 @@
<?php
namespace App\Services;
use App\Exceptions\BizException;
use EasyWeChat\Factory;
use Illuminate\Support\Facades\Http;
class WxMpService
{
public static function make()
{
return new static();
}
/**
* 发货信息录入
* https://developers.weixin.qq.com/miniprogram/dev/server/API/order_shipping/api_uploadshippinginfo.html
*/
public function uploadShippingInfo($data)
{
$app = Factory::miniProgram(config('wechat.mini_program.default'));
$accessToken = $app->access_token->getToken()['access_token'];
$url = "https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token=" . $accessToken;
$data = [
"order_key" => [
"order_number_type" => 1,
"out_trade_no" => "",
],
"logistics_type" => 4
];
$response = Http::post($url, $data);
$response->throw();
$result = $response->json();
$code = data_get($result, "errcode");
if ($code != 0) {
throw new BizException("微信小程序-发货信息录入, 失败: (".$code.")" . data_get($result, "errmsg"));
}
}
}

View File

@ -25,6 +25,7 @@ return [
'items' => '品类', 'items' => '品类',
'user_remark' => '用户备注', 'user_remark' => '用户备注',
'staff_remark' => '员工备注', 'staff_remark' => '员工备注',
'coupon_discount_amount' => '优惠券抵扣',
], ],
'options' => [ 'options' => [
], ],