ldc-boutique-cnc/app/Lib/Helpers/ToolboxHelper.php
2023-05-23 09:32:54 +02:00

637 lines
25 KiB
PHP

<?php
namespace App\Lib\Helpers;
use App\Enums\Discount\DiscountType;
use App\Enums\GeneralConfigType;
use App\Enums\Product\TaxRate;
use App\Models\Merchandise\Product;
use App\Models\Shop\Order;
use App\Models\Site\GeneralConfig;
use Gloudemans\Shoppingcart\Cart;
use Gloudemans\Shoppingcart\CartItem;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Mccarlosen\LaravelMpdf\LaravelMpdf;
use Money\Money;
use Mpdf\Config\FontVariables;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ToolboxHelper
{
/**
* Update basket quantities using cart caluculator
*
* @param object $basket
* @param array $newQuantities Array of [id => qty] or [rowId => qty].
* @return object
* @throws \Illuminate\Validation\ValidationException If a product from the basket is not accessible anymore.
*/
public static function updateBasketAmounts(object $basket, array $newQuantities): object
{
$newQuantities = \collect($newQuantities);
// ! Ici on veux réutiliser le cart calculator.
$cart = self::createVirtualCart(\collect($basket)
->map(function (object $basketItem) {
$cartItem = self::basketItemObjectToCartItem($basketItem);
$cartItem->associate(Product::query()->where('id', $cartItem->id)->first());
return $cartItem;
})
// * Enlever les produits qui ne seront pas livrés.
->filter(function (CartItem $basketItem) {
return $basketItem->qty;
}));
// * Update virtual basket with actual content
\collect($basket)->each(function (object $basketItem) use ($newQuantities, $cart) {
/** @var \Gloudemans\Shoppingcart\CartItem $cartItem */
$cartItem = $cart->search(function (CartItem $cartItem) use ($basketItem) {
return $cartItem->id === $basketItem->id;
})->first();
$newQty = \intval(
$newQuantities->get($basketItem->id, $newQuantities->get($basketItem->rowId))
);
// * Update Qty or remove item from the cart.
if (!$newQty) {
$cart->remove($cartItem->rowId);
return;
}
$cartItem->setQuantity($newQty);
// ! https://github.com/bumbummen99/LaravelShoppingcart/pull/181
// * Note Lib is is actually refactoring for laravel 9 php8 only
// * https://github.com/bumbummen99/LaravelShoppingcart/pull/145
// ! These lines are required to prevent amount bug.
$cartItem->setTaxRate($basketItem->options->tax_rate / 100);
$cartItem->setDiscountRate($basketItem->options->discount_rate);
});
$virtualOrder = new Order();
$virtualOrder->assignBasket($cart);
return (object)$virtualOrder->basket;
}
/**
* Crete a virtual cart to use calculator
*
* @param \Illuminate\Support\Collection $cartItemList
* @return \Gloudemans\Shoppingcart\Cart
*/
public static function createVirtualCart(Collection $cartItemList): Cart
{
// * Get temp cart instance.
/** @var \Gloudemans\Shoppingcart\Cart */
$basket = app('basket');
$virtualCart = $basket->instance(\strval(\uniqid('_temp', true)));
$cartItemList->each(function (CartItem $cartItem) use ($virtualCart) {
if (!($cartItem instanceof CartItem)) {
throw new \RuntimeException('Cart item shall be a ' . CartItem::class);
}
$virtualCart->addCartItem($cartItem);
return $cartItem;
});
return $virtualCart;
}
/**
* Parse a basket item from DB to and actual BasketItem
*
* @param object $basketItem This should actually come from DB.
* @return \Gloudemans\Shoppingcart\CartItem
* @throws \RuntimeException If basketItem format is not valid.
*/
public static function basketItemObjectToCartItem(object $basketItem): CartItem
{
if (!self::basketObjectIsValid($basketItem)) {
throw new \RuntimeException('Cannot validate basket item');
}
$cartItem = CartItem::fromArray(\json_decode(\json_encode($basketItem), true));
$cartItem->setQuantity($basketItem->qty);
// ! https://github.com/bumbummen99/LaravelShoppingcart/pull/181
// * Note Lib is is actually refactoring for laravel 9 php8 only
// * https://github.com/bumbummen99/LaravelShoppingcart/pull/145
// ! These lines are required to prevent amount bug.
$cartItem->setTaxRate($basketItem->options->tax_rate / 100);
$cartItem->setDiscountRate($basketItem->options->discount_rate);
return $cartItem;
}
/**
* Get basket as product list
*
* @param \Gloudemans\Shoppingcart\Cart $basket
* @return \Illuminate\Support\Collection
*/
public static function cartToList(Cart $basket): Collection
{
return $basket->content()->map(function (CartItem $cartItem) {
return \json_decode(\json_encode($cartItem->toArray()));
});
}
/**
* Validate Basket object
*
* ! This validation format should be equals to whats in
* index.d.ts bItem and also into Fo/BasketController::addToBasket
*
* ! This does report validation errors.
*
* @param object $basket
* @return boolean
*/
public static function basketObjectIsValid(object $basket): bool
{
$validator = Validator::make(\json_decode(\json_encode($basket), true), [
'rowId' => 'required|regex:/^[0-9a-z]{32}$/',
'id' => 'required|integer|min:1',
'name' => 'required|string|min:1|max:255',
'qty' => 'required|integer|min:1',
'price' => 'required|integer|min:1',
'weight' => 'required|integer|min:0',
'options.reference' => 'required|string|max:255',
'options.slug' => 'required|string|min:1|max:255',
'options.picture' => 'nullable|string|min:1|max:255',
'options.sell_unit' => 'required|string|min:1|max:255',
'options.weight_with_unit' => 'required|string|min:1|max:255',
'options.discount_type' => 'nullable|in:' . \collect(DiscountType::toValues())->implode(','),
'options.discount_rate' => 'required|numeric|min:0',
'options.price_ttc' => 'required|integer|min:1',
'options.undiscounted_price_ttc' => 'required|integer|min:1',
'options.tax_rate' => [
'required', 'numeric', Rule::in(\collect(TaxRate::toArray())
->map(function (TaxRate $taxRate) {
return $taxRate->rate;
}))
],
'discount' => 'required|integer|min:0',
'tax' => 'required|integer|min:0',
'subtotal' => 'required|integer|min:1'
]);
try {
$validator->validate();
return true;
} catch (ValidationException $e) {
\report(new \RuntimeException('Could not validate basket object ' .
\implode(',', collect($e->errors())->flatten()->all())));
return false;
}
}
/**
* Get displayable price value
*
* @param mixed $value Amount in cents.
* @param string $suffix Append some text.
* @return string
*/
public static function fmtCurrency($value, string $suffix = 'TTC'): string
{
return \trim(sprintf(
'%s %s',
app('phpMoneyFormatter')->format(new Money($value, app('phpMoneyCurrency'))),
$suffix
));
}
/**
* Get displayable price value
*
* @param mixed $value
* @return string
*/
public static function fmtSellUnit($value): string
{
return !empty($value) ? "/ {$value}" : '';
}
/**
* Generate an unique reference from model on reference column by default
*
* * The pattern is YYYYMMDDXXXXXXXXXX where X is 10 random numbers.
*
* @param string $modelClass The model to use for reference.
* @param integer $recursiveIncrement Used for internal only.
* @param string $column The column name where are stored references.
* @return string
* @throws \RuntimeException If $modelClass does not extends \Illuminate\Database\Eloquent\Model.
*/
public static function getUniqueReference(
string $modelClass,
int $recursiveIncrement = 0,
string $column = 'reference'
): string {
if (!\is_subclass_of($modelClass, Model::class)) {
throw new \RuntimeException("{$modelClass} have to extend " . Model::class);
}
/** @var \Illuminate\Database\Eloquent\Model */
$model = new $modelClass();
$counter = $model::query()->whereDate('created_at', Carbon::today())->count() + 1 + $recursiveIncrement;
$reference = Carbon::now()->isoFormat('YYYYMMDD') . str_pad(crc32(uniqid('', true)), 10, '0', STR_PAD_LEFT);
if ($model::query()->where($column, $reference)->exists()) {
// * Recursive counter to determinate reference unicity.
return self::getUniqueReference($model, $counter + 1);
}
return $reference;
}
/**
* Strip scheme (https) on oembed urls
* Only embed allowed are for MP4 self hosted videos right now
*
* https is force by ckeditor MediaElements which natively supports
* only cloud hosted medias (youtube, spotify etc..)
*
* @param string $input
* @return string
*/
public static function stripCkEditorSchemesOnMediaInputs(string $input): string
{
$input = \preg_replace('/(?<=<oembed url=")(https:\/\/)(?=.*?">)/m', '', $input);
return $input;
}
/**
* Assert That no Back Office url is present on txt
*
* @param string $field
* @param string $text
* @return string
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalBoUrls(string $field, string $text): string
{
$routes = Route::getRoutes();
// * Url replace and email replace.
$text = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
function ($matches) use ($routes, $field) {
$match = collect($matches)->first() ?? '';
$url = $match;
try {
$request = Request::create($url);
// If a route is match then it do not throws exception.
if (
\parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) &&
trim($routes->match($request)->getPrefix() ?? '', '/') === 'bo'
) {
// Removes the matched url.
throw ValidationException::withMessages([
$field => ':attribute : Il est interdit de coller une url du back office dans ce champ'
]);
}
return $match;
} catch (NotFoundHttpException $e) {
return $match;
}
},
$text
);
return $text;
}
/**
* Assert That only external url is present on txt
*
* @param string $field
* @param string $text
* @return string
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalUrls(string $field, string $text): string
{
self::assertNoInternalBoUrls($field, $text);
$routes = Route::getRoutes();
// * Url replace and email replace.
$text = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
function ($matches) use ($routes, $field) {
$match = collect($matches)->first() ?? '';
$url = $match;
try {
$request = Request::create($url);
// If a route is match then it do not throws exception.
if (
\parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) &&
$routes->match($request)
) {
// Removes the matched url.
throw ValidationException::withMessages([
$field => sprintf(
':attribute : Il est interdit de coller une url de %s dans ce champ',
config('app.name')
)
]);
}
return $match;
} catch (NotFoundHttpException $e) {
return $match;
}
},
$text
);
return $text;
}
/**
* Mutltibyte string replace
* @param mixed $search
* @param mixed $replace
* @param mixed $subject
* @param integer $count
* @return mixed
*/
public static function mbReplace($search, $replace, $subject, int &$count = 0)
{
if (!is_array($search) && is_array($replace)) {
return false;
}
if (is_array($subject)) {
// Call mb_replace for each single string in $subject .
foreach ($subject as &$string) {
$string = &self::mbReplace($search, $replace, $string, $count);
}
} elseif (is_array($search)) {
if (!is_array($replace)) {
foreach ($search as &$string) {
$subject = self::mbReplace($string, $replace, $subject, $count);
}
} else {
$n = max(count($search), count($replace));
while ($n--) {
$subject = self::mbReplace(current($search), current($replace), $subject, $count);
next($search);
next($replace);
}
}
} else {
$parts = mb_split(preg_quote($search), $subject);
$count = count($parts) - 1;
$subject = implode($replace, $parts);
} //end if
return $subject;
}
/**
* Asserts that the field is unique
*
* @param string $table
* @param string $field
* @param mixed $value
* @param integer|null $id
* @param callable|null $query
* @return void
* @throws \Illuminate\Validation\ValidationException If the field is not unique.
*/
public static function assertFieldIsUnique(
string $table,
string $field,
$value,
?int $id = null,
?callable $query = null
) {
if (!$query) {
Validator::make([$field => $value], [
self::mbReplace('.', '\.', $field) => Rule::unique($table, $field)->ignore($id),
], [
self::mbReplace('.', '\.', "{$field}.unique")
=> trans('La valeur :value pour le champ :attribute est déjà utilisé', [
'attribute' => $field,
'value' => $value
])
])->validate();
return;
}
Validator::make([$field => $value], [
$field => Rule::unique($table)->where($query)->ignore($id),
], [
$field . '.unique' => trans('La valeur pour le champ :attribute est déjà utilisé', [
'attribute' => $field
])
])->validate();
}
/**
* Generate a unique reference
*
* @return string
*/
public static function getUniqueProductReference(): string
{
\mt_srand();
$ref = Str::padLeft(\mt_rand(1, 99999999), 8, '0');
if (Product::where('reference', $ref)->count()) {
return self::getUniqueProductReference();
}
return $ref;
}
/**
* Convert deeply an object to array
*
* @param array|object $input
* @return array
* @throws \RuntimeException If $input var is a scalar value.
*/
public static function objectToArray($input): array
{
// Only process if it's an object or array being passed to the function.
if (is_object($input) or is_array($input)) {
$ret = (array)$input;
foreach ($ret as &$item) {
// Recursively process EACH element only if not scalar.
$item = (is_object($item) or is_array($item)) ?
self::objectToArray($item) : $item;
}
return $ret;
} else {
throw new \RuntimeException('cannot handle $input of type ' . \gettype($input));
}
}
/**
* Convert deeply an array to object
*
* @param array|object $input
* @return object
* @throws \RuntimeException If $input var is a scalar value.
*/
public static function arrayToObject($input): object
{
// Only process if it's an object or array being passed to the function.
if (is_object($input) or is_array($input)) {
$ret = (object)$input;
foreach ($ret as &$item) {
// Recursively process EACH element only if not scalar.
$item = (is_object($item) or is_array($item)) ?
self::arrayToObject($item) : $item;
}
return $ret;
} else {
throw new \RuntimeException('cannot handle $input of type ' . \gettype($input));
}
}
/**
* Validate recaptcha
*
* @param \Illuminate\Http\Request $request
* @param boolean $isV3
* @return void
*/
public static function validateRecaptcha(Request $request, bool $isV3 = true)
{
Validator::validate($request->all(), [
'g-recaptcha-response' => [
'required', 'string',
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
function ($attribute, $value, $fail) use ($isV3) {
try {
$response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
'secret' => config('services.google.recaptcha.private'),
'response' => \strval($value),
'remoteip' => app('clientIp')
]);
if ($e = $response->toException()) {
throw $e;
}
if ($isV3) {
if (
!$response->json('success') or
!$response->json('challenge_ts') or
Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or
$response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST) or
!$response->json('score')
) {
throw new \RuntimeException();
}
if (\floatval($response->json('score')) < 0.8) {
$fail("Vous n'avez pas obtenu un score assez élevé lors de la validation du recaptcha");
}
return;
}
if (
!$response->json('success') or
!$response->json('challenge_ts') or
Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or
$response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST)
) {
throw new \RuntimeException();
}
} catch (\Exception $e) {
$fail('Erreur lors de la validation du formulaire veuillez réssayer');
} //end try
}
]
], [], ['g-recaptcha-response' => 'reCaptcha']);
}
/**
* Generate Invoice
*
* @param \App\Models\Shop\Order $order
* @param \Illuminate\Support\Carbon $date
* @param string $title
* @param string $documentType
* @param object $items
* @param string $transactionReference
* @return \Mccarlosen\LaravelMpdf\LaravelMpdf
*/
public static function generateInvoice(
Order $order,
Carbon $date,
string $title,
string $documentType,
object $items,
string $transactionReference
): LaravelMpdf {
/** @var \App\Models\Site\GeneralConfig */
$config = GeneralConfig::getCached()->where('type', GeneralConfigType::global())->firstOrFail();
$additionalData = $config->data;
/** @var \Mccarlosen\LaravelMpdf\LaravelMpdfWrapper */
$mpdf = app('laravel-mpdf');
$defaultFontConfig = (new FontVariables())->getDefaults();
$fontData = $defaultFontConfig['fontdata'];
$invoiceBasketItems = \collect($items);
/**
* 7.5pt 10px
* 9pt 12px
* 10.5pt 14px
* 12pt 16px
* 13.5pt 18px
*/
/** @var \Mccarlosen\LaravelMpdf\LaravelMpdf */
$pdf = $mpdf->loadView(
'back::documents.order-invoice',
[
'title' => $title,
'billing' => !empty($order->billing->firstname) ? $order->billing : $order->shipping,
'shipping' => $order->shipping,
'logo' => \public_path(resize($additionalData->logo->value ?? '', 'BoutiqueLogo')),
'introLines' => \collect($additionalData)->map(function (object $input, string $inputName) {
return Str::substr($inputName, 0, -1) === 'invoice_line_' ? $input->value : null;
})->filter()->all(),
'orderReference' => $order->reference,
'transactionReference' => $transactionReference,
'date' => $date,
'documentType' => $documentType,
'basket' => $invoiceBasketItems,
'totalTtc' => $invoiceBasketItems->sum('subtotal'),
'totalTaxes' => $invoiceBasketItems->sum('tax')
],
[],
[
'mode' => 'utf-8',
'format' => 'A4',
// * 10.5pt = 14px
'default_font_size' => 10.5,
'default_font' => 'roboto',
'margin_left' => 5,
'margin_right' => 5,
'margin_top' => 5,
'margin_bottom' => 5,
'margin_header' => 0,
'margin_footer' => 5,
'orientation' => 'P',
'title' => $title,
'author' => \config('app.name'),
'watermark' => '',
'show_watermark' => false,
'watermark_font' => 'sans-serif',
'display_mode' => 'fullpage',
'watermark_text_alpha' => 0.1,
'custom_font_dir' => '',
'custom_font_data' => [],
'auto_language_detection' => false,
'temp_dir' => rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR),
'pdfa' => false,
'pdfaauto' => false,
'fontdata' => $fontData + [
'roboto' => [
'R' => \resource_path('assets/fonts/bo/roboto/RobotoMono-Regular.ttf'),
'B' => \resource_path('assets/fonts/bo/roboto/RobotoMono-Bold.ttf'),
'I' => \resource_path('assets/fonts/bo/roboto/RobotoMono-Italic.ttf'),
'BI' => \resource_path('assets/fonts/bo/roboto/RobotoMono-BoldItalic.ttf')
]
]
]
);
return $pdf;
}
}