ldc-boutique-cnc/app/Http/Controllers/Bo/OrderController.php
2023-05-23 19:08:52 +02:00

587 lines
25 KiB
PHP

<?php
namespace App\Http\Controllers\Bo;
use App\Enums\Order\DeliveryModeEnum;
use App\Enums\Order\DeliveryStatus;
use App\Enums\Order\PaymentModeEnum;
use App\Enums\Order\TransactionStatus;
use App\Enums\Order\TransactionType;
use App\Enums\Order\ValidationStatus;
use App\Events\OrderIsRefunded;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Orders\ChangeOrderDeliveryStatusRequest;
use App\Http\Requests\Bo\Orders\ChangeOrderValidationStatusRequest;
use App\Http\Requests\Bo\PrepareOrderRequest;
use App\Http\Requests\Bo\ResolveOrderRequest;
use App\Jobs\Order\RefundOrder;
use App\Lib\Helpers\ToolboxHelper;
use App\Models\Payment\Transaction;
use App\Models\Shop\Order;
use Gloudemans\Shoppingcart\CartItem;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\View\View;
class OrderController extends Controller
{
/**
* If true the index action will only display today orders
* @var false
*/
private $indexModeTodayOnly = false;
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
*/
public function index(Request $request): View
{
/** @var \Illuminate\Database\Eloquent\Builder $orders */
$orders = Order::query();
if ($request->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<object> */
$orderedItems = \collect($order->basket);
// * getRemainingUnrefundedItems Modifie les rowId originaux, il faut les restaurer pour la comparaison.
/** @var \Illuminate\Support\Collection<string,string> */
$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<object> */
$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;
$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();
}
// * 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}");
}
}