search) { $this->searchQuery( $orders, $request->search, function (Builder $q) use ($request) { $q->orWhereHas('transactions', function (Builder $q) use ($request) { $foreignIds = \collect(PaymentModeEnum::toArray()) ->map(function (PaymentModeEnum $paymentMode) use ($request) { return Transaction::getPaymentForeignKeyName( $paymentMode, $request->search ); }); $q->whereIn('foreign_id', $foreignIds) ->orWhereIn('foreign_refund_id', $foreignIds); }); }, 'reference', 'email', ); }//end if $this->sortQuery($orders); $searchFields = trans('Réference,Email,Pay.Ref'); if ($this->indexModeTodayOnly) { $orders->whereHas('timeslot', function (Builder $q) { $q->whereDate('slot', \now()->today()); }); } $orders->orderBy('created_at', 'DESC'); $orders->with(['deliveryMode', 'trashedTimeSlot.shop']); $orders = $orders->paginate(10); return view('back::pages.orders.index', compact('orders', 'searchFields')); } /** * Display a listing of the resource. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory */ public function indexToday(Request $request): View { $this->indexModeTodayOnly = true; /** @var \Illuminate\View\View $response */ $response = $this->callAction('index', ['request' => $request]); return $response; } /** * Display the specified resource. * * @param \App\Models\Shop\Order $order * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory */ public function show(Order $order): View { $order->load('transactions'); return view('back::pages.orders.show', compact('order')); } /** * Mettre a jour le status de validation d'une commande * * @param \App\Http\Requests\Bo\Orders\ChangeOrderValidationStatusRequest $request * @param \App\Models\Shop\Order $order * @return \Illuminate\Http\RedirectResponse */ public function changeValidationStatus(ChangeOrderValidationStatusRequest $request, Order $order): RedirectResponse { $this->authorize('changeValidationStatus', $order); return DB::transaction(function () use ($request, $order) { $order->validation_status = $request->validation_status; switch ($request->validation_status) { case ValidationStatus::canceled()->value: $order->delivery_status = DeliveryStatus::canceled(); \session()->flash('bo.warning', trans('La commande est marquée comme annulée')); // Fall through. case ValidationStatus::validated()->value: $order->saveOrFail(); return \response()->redirectToRoute('bo.orders.show', $order)->with('bo.success', trans( ':model a été mis à jour', ['model' => Str::singular(trans('models.classes.orders'))] )); case ValidationStatus::pending()->value: return \back()->with('bo.errors', \trans('Il faut choisir un statut pour cette commande.')); default: throw new \RuntimeException("Unhandled status `{$request->validation_status}`"); }//end switch }); } /** * Mettre a jour le status de livraison d'une commande * * @param \App\Http\Requests\Bo\Orders\ChangeOrderDeliveryStatusRequest $request * @param \App\Models\Shop\Order $order * @return \Illuminate\Http\RedirectResponse */ public function changeDeliveryStatus(ChangeOrderDeliveryStatusRequest $request, Order $order): RedirectResponse { $this->authorize('changeDeliveryStatus', $order); return DB::transaction(function () use ($request, $order) { $order->delivery_status = $request->delivery_status; switch (true) { // * La commande va être préparé ou est déja en cours de préparation case $order->delivery_status->equals(DeliveryStatus::preparation()) or $order->delivery_status->equals(DeliveryStatus::preparation()): $order->delivery_status = DeliveryStatus::preparation(); $order->shipped_basket = ''; $order->saveOrFail(); return \redirect()->route('bo.orders.prepare', $order); // * La commande est récupérée. case $order->delivery_status->equals(DeliveryStatus::recovered()): // * Déclarer la commande comme réglée si récupération à la boutique. if ($order->deliveryMode->mode->equals(DeliveryModeEnum::inShop())) { // ! There should be one and only one inShop transaction ! /** @var \App\Models\Payment\Transaction|null */ $inShopTransaction = $order->transactions() ->where('type', TransactionType::debit()) ->where('payment_mode', PaymentModeEnum::inShop()) ->latest()->first(); $paidTransactions = $order->transactions()->where('type', TransactionType::debit()) ->where('status', TransactionStatus::validated())->count(); // * Check if there are not paid transactions then // * we should have one pending inShop transaction. if (!$paidTransactions and !$inShopTransaction) { throw new \RuntimeException( "There should be one inshop transaction for order '{$order->id}'" ); } if ($inShopTransaction) { $inShopTransaction->status = TransactionStatus::validated(); $inShopTransaction->saveOrFail(); } } else { throw new \RuntimeException("Unhandled delivery mode other than in shop"); } //end if // Fall through. // * La commande est prête. case $order->delivery_status->equals(DeliveryStatus::ready()): $order->saveOrFail(); return \back()->with('bo.success', trans( ':model a été mis à jour', ['model' => Str::singular(trans('models.classes.orders'))] )); default: throw new \RuntimeException("Unhandled status `{$request->delivery_status}`"); }//end switch }); } /** * Show the order resolve form * * @param \App\Models\Shop\Order $order * @return \Illuminate\View\View * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass */ public function resolve(Order $order): View { // phpcs:enable $this->authorize('resolve', $order); $transactionModel = $order->transactions()->where('needs_validation', true)->first(); // * Diff between Basket - (Delivered items - refunded transaction items) $remainingItems = $order->getRemainingUnrefundedItems(); return \view('back::pages.orders.resolve', \compact('order', 'transactionModel', 'remainingItems')); } /** * Validate order resolution * * @param \App\Http\Requests\Bo\ResolveOrderRequest $request * @param \App\Models\Shop\Order $order * @return \Illuminate\Http\RedirectResponse */ public function resolveSubmit(ResolveOrderRequest $request, Order $order): RedirectResponse { return DB::transaction(function () use ($request, $order) { /** @var \App\Models\Payment\Transaction */ $transaction = $request->transaction; /** @var \Illuminate\Support\Collection */ $orderedItems = \collect($order->basket); // * getRemainingUnrefundedItems Modifie les rowId originaux, il faut les restaurer pour la comparaison. /** @var \Illuminate\Support\Collection */ $refundedItems = $request->collect('items')->mapWithKeys(function (array $item) use ($orderedItems) { $item = (object)$item; $rowId = $orderedItems->where('id', '=', $item->index)->first()->rowId; return [$rowId => $item->quantity]; }); /** @var \Illuminate\Support\Collection */ $refundedTransactionsItems = $orderedItems->map(function (object $basketItem) use ($refundedItems) { if ($refundedItems->has($basketItem->rowId)) { $basketItem->qty = \intval($refundedItems->get($basketItem->rowId)); return $basketItem; } return null; }); $refundedTransactionsItems = \collect(ToolboxHelper::updateBasketAmounts( $orderedItems, $refundedItems->all() )); // ? La transaction concerne des produits ? if ($refundedTransactionsItems->count()) { // * Estampiller les ligne avec la raison $refundedTransactionsItems->map(function (object $basketItem) use ($request) { $basketItem->name = "{$request->reason} - {$basketItem->name}"; return $basketItem; }); } $montantRembourse = \intval($refundedTransactionsItems->sum('subtotal')); // ? Le montant des produits remboursé est inférieur a celui de la transaction. if ($montantRembourse < \intval($transaction->amount->getAmount())) { // * Créer un dummy product pour représenter la transaction. $cartItemOptions = \array_merge(static::$cartItemOptionsSkel, [ 'virtual' => true, 'price_ttc' => \intval($transaction->amount->getAmount()) - $montantRembourse, ]); $cartItem = CartItem::fromArray([ 'id' => \str_replace('.', '', \uniqid('', true)), 'name' => $request->reason, 'price' => \intval($transaction->amount->getAmount()) - $montantRembourse, 'weight' => 0, 'options' => $cartItemOptions ]); $cartItem->setQuantity(1); // * On envoie la ligne virtuelle en haut de la liste. $refundedTransactionsItems->put($cartItem->rowId, \json_decode($cartItem->toJson())); $refundedTransactionsItems = $refundedTransactionsItems->reverse(); } //end if $transaction->items = $refundedTransactionsItems->map(function (object $basketItem) { $basketItem->tax = $basketItem->tax * -1; $basketItem->price = $basketItem->price * -1; $basketItem->subtotal = $basketItem->subtotal * -1; $basketItem->options->price_ttc = $basketItem->options->price_ttc * -1; $basketItem->options->undiscounted_price_ttc = $basketItem->options->undiscounted_price_ttc * -1; return $basketItem; }); $transaction->needs_validation = false; // ? The resolution means a complete refund ? if ($order->isCompleteRefundWithTransaction($transaction)) { // * Calculate unrefunded tax $unRefundedTax = \abs(\intval(\collect($order->getRemainingUnrefundedItems())->sum(function (object $item) { return $item->tax * $item->qty; }))); // * Calculate justified items tax $justifiedItemTax = \abs(\intval(\collect($transaction->items)->sum(function (object $item) { return \optional($item->options)->virtual ? 0 : $item->tax * $item->qty; }))); // * Update remaining Tax to exclude justified. $remainingTax = ($unRefundedTax - $justifiedItemTax) * -1; // * Set remaining tax to virtual $transaction->items = (object)\collect($transaction->items) ->map(function (object $item) use ($remainingTax) { if (\optional($item->options)->virtual) { $item->tax = $remainingTax; } return $item; })->all(); } //end if $transaction->saveOrFail(); OrderIsRefunded::dispatch( $order, $transaction ); return \response()->redirectToRoute('bo.orders.show', $order) ->with('success', \trans('La transaction est marquée comme résolue')); }); } /** * Show the order prepare form * * @param \App\Models\Shop\Order $order * @return \Illuminate\View\View * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass */ public function prepare(Order $order): View { // phpcs:enable $this->authorize('prepare', $order); $remainingItems = $order->getRemainingUnrefundedItems(); return \view('back::pages.orders.prepare', \compact('order', 'remainingItems')); } /** * Validate order preparation * * @param \App\Http\Requests\Bo\PrepareOrderRequest $request * @param \App\Models\Shop\Order $order * @return \Illuminate\Http\RedirectResponse */ public function prepareSubmit(PrepareOrderRequest $request, Order $order): RedirectResponse { $this->authorize('prepare', $order); return DB::transaction(function () use ($request, $order) { // * Les items qu'il reste sur la commande (non remboursés). $remainingItems = $order->getRemainingUnrefundedItems(); // * On met à jour cette liste avec les items effectivement préparés dans la commande. $shipped_basket = \collect(ToolboxHelper::updateBasketAmounts( $remainingItems, $request->items )); // * On met à jour la liste avec les items qui vont être remboursés. $itemsToRefund = $remainingItems->pluck('qty', 'id') ->mapWithKeys(function ($qty, string $id) use ($request) { return [ $id => \intval($qty) - \intval(\collect($request->items)->get($id, 0)) ]; }); $refundedTransactionsItems = \collect(ToolboxHelper::updateBasketAmounts( $remainingItems, $itemsToRefund->all() )); $order->shipped_basket = $shipped_basket; $order->delivery_status = DeliveryStatus::ready(); // * Save Shipped basket $order->saveQuietly(); // ? Le montant va exceder ce que le client a payé ? if ($request->willExceedOrderAmount) { // * Annulation et remboursement du reste. // * Créer un dummy product pour représenter la transaction. $cartItemOptions = \array_merge(static::$cartItemOptionsSkel, [ 'virtual' => true, 'price_ttc' => $order->total_ttc - $order->total_refund, ]); $cartItem = CartItem::fromArray([ 'id' => \str_replace('.', '', \uniqid('', true)), 'name' => trans('Remboursement commande'), 'price' => $order->total_ttc - $order->total_refund, 'weight' => 0, 'options' => $cartItemOptions ]); $cartItem->setQuantity(1); $this->refundTransaction( $order, $order->total_ttc - $order->total_refund, // * Negate all items prices (already refunded) \collect([ $cartItem->rowId => \json_decode($cartItem->toJson()) ])->map(function (object $basketItem) { $basketItem->tax = $basketItem->tax * -1; $basketItem->price = $basketItem->price * -1; $basketItem->subtotal = $basketItem->subtotal * -1; $basketItem->options->price_ttc = $basketItem->options->price_ttc * -1; $basketItem->options->undiscounted_price_ttc = $basketItem->options->undiscounted_price_ttc * -1; return $basketItem; }) ); // * Mettre a jour la commande. $order->saveOrFail(); // * Cancel Order completely. /** @var \Illuminate\Http\RedirectResponse */ return $this->callAction('changeValidationStatus', [ ChangeOrderValidationStatusRequest::create( \route('bo.orders.validation-status', $order), 'POST', [ 'validation_status' => ValidationStatus::canceled()->value ] ), $order ]); } //end if // ? La commande va devoir être remboursé completement ? if ($request->willNeedCompleteRefund) { $this->refundTransaction( $order, $order->total_ttc - $order->total_refund, // * Negate all items prices (already refunded) $order->getRemainingUnrefundedItems()->map(function (object $basketItem) { $basketItem->tax = $basketItem->tax * -1; $basketItem->price = $basketItem->price * -1; $basketItem->subtotal = $basketItem->subtotal * -1; $basketItem->options->price_ttc = $basketItem->options->price_ttc * -1; $basketItem->options->undiscounted_price_ttc = $basketItem->options->undiscounted_price_ttc * -1; return $basketItem; }) ); // * Mettre a jour la commande. $order->saveOrFail(); // * Cancel Order completely. /** @var \Illuminate\Http\RedirectResponse */ return $this->callAction('changeValidationStatus', [ ChangeOrderValidationStatusRequest::create( \route('bo.orders.validation-status', $order), 'POST', [ 'validation_status' => ValidationStatus::canceled()->value ] ), $order ]); } //end if // ? Il faudra emettre un remboursement pour le reste ? if ($request->willNeedRefund and (bool)intval($refundedTransactionsItems->sum('subtotal'))) { $this->refundTransaction( $order, $refundedTransactionsItems->sum('subtotal'), // * Negate all items prices (already refunded) $refundedTransactionsItems->map(function (object $basketItem) { $basketItem->tax = $basketItem->tax * -1; $basketItem->price = $basketItem->price * -1; $basketItem->subtotal = $basketItem->subtotal * -1; $basketItem->options->price_ttc = $basketItem->options->price_ttc * -1; $basketItem->options->undiscounted_price_ttc = $basketItem->options->undiscounted_price_ttc * -1; return $basketItem; }) ); } // * Mettre a jour la commande. $order->saveOrFail(); return \redirect()->route('bo.orders.show', $order)->with('bo.success', trans( ':model a été mis à jour', ['model' => Str::singular(trans('models.classes.orders'))] )); }); } /** * Complete order refund and cancel * * @param \App\Models\Shop\Order $order * @return \Illuminate\Http\RedirectResponse */ public function cancelRefundSubmit(Order $order): RedirectResponse { $this->authorize('refund', $order); return DB::transaction(function () use ($order) { $this->callAction('refundSubmit', [$order]); // * Cancel Order completely. /** @var \Illuminate\Http\RedirectResponse */ return $this->callAction('changeValidationStatus', [ ChangeOrderValidationStatusRequest::create( \route('bo.orders.validation-status', $order), 'POST', [ 'validation_status' => ValidationStatus::canceled()->value ] ), $order ]); }); } /** * Complete order refund * * @param \App\Models\Shop\Order $order * @return \Illuminate\Http\RedirectResponse */ public function refundSubmit(Order $order): RedirectResponse { $this->authorize('refund', $order); return DB::transaction(function () use ($order) { $this->refundTransaction( $order, $order->total_ttc - $order->total_refund, $order->getItemForOrderCancelling() ); return \back()->with('bo.success', trans( ':model a été mis à jour', ['model' => Str::singular(trans('models.classes.orders'))] )); }); } /** * Refund a transaction * * @param \App\Models\Shop\Order $order * @param integer $amount * @param object $items * @return void * @throws \RuntimeException If the payment transaction cannot be fetched for the concerned order. */ private function refundTransaction( Order $order, int $amount, object $items ): void { if (!($transaction = $order->getTransactionUsedForPayment())) { throw new \RuntimeException("Could not fetch paiment transaction for order {$order->id}"); } // ? Le paiment se fait en boutique ? if ($transaction->payment_mode->equals(PaymentModeEnum::inShop())) { // * Execute juste l'adaptateur. $newTransaction = $transaction->getAdapter()->doRefundCustomer( $transaction, $amount, $items ); // ? On est sur la même transaction ? if ($newTransaction->exists and $newTransaction->id === $transaction->id) { // * Elle a été anullée. $previousMessage = session()->get('bo.warning', ''); session()->flash('bo.warning', \trans( 'La commande est annulée.' ) . " {$previousMessage}"); return; } // * Une nouvelle à été créé. $previousMessage = session()->get('bo.error', ''); session()->flash( 'bo.info', \trans( 'Une nouvelle transaction est crée avec les produits effectivement préparés.' ) . " {$previousMessage}" ); return; } //end if RefundOrder::dispatchSync( $order, $transaction, $amount, $items ); $previousMessage = session()->get('bo.info', ''); session()->flash('bo.info', \trans( 'Un remboursement va être émit au client' ) . " \n{$previousMessage}"); } }