ldc-showroom-bo/app/Jobs/ShowroomExport.php

379 lines
13 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\Brand;
use App\Models\Font;
use App\Models\Product;
use App\Models\Range;
use App\Models\Settings;
use App\Models\Tenant;
use Carsdotcom\JsonSchemaValidation\SchemaValidator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
class ShowroomExport implements ShouldQueue, ShouldBeUnique
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Export dir
*
* @var string
*/
private string $exportFolder = 'app/export';
/**
* Working dir
*
* @var string
*/
private string $tempFolder = 'app/temp/export';
/**
* Working dir
*
* @var string
*/
private string $tenantId = '';
/**
* The number of seconds after which the job's unique lock will be released.
*
* @var integer
*/
public int $uniqueFor = 3600;
/**
* Create a new job instance.
*
* @param string $tenantId
*
* @throws RuntimeException If there's no Tenant for the given id.
*/
public function __construct(string $tenantId)
{
if (!Tenant::query()->where('id', $tenantId)->first()) {
throw new RuntimeException(\sprintf('No Tenant found for id : %s', $tenantId));
}
$this->exportFolder = \storage_path($this->exportFolder);
$this->tempFolder = \storage_path($this->tempFolder);
$this->tenantId = $tenantId;
}
/**
* Execute the job.
*
* @return void
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException If a schema file could not be found.
*/
public function handle(): void
{
$data = $this->getData();
$associatedFileList = $this->getAssociatedFileList($data);
$fileContent = $this->getFile($data);
$this->assertSchemaIsValid('app.schema.json', json_encode($fileContent));
$filesToZip = $this->preparePicturesForZip($associatedFileList);
$dataFilePath = "{$this->tempFolder}/data.json";
// * Make json File add add to files to zip.
File::put($dataFilePath, json_encode($fileContent), true);
if (!File::exists($this->exportFolder)) {
File::makeDirectory($this->exportFolder, 0700, true);
}
// * Empty too old files
collect(File::allFiles($this->exportFolder))->each(function (\SplFileInfo $file) {
$timestamp = $file->getCTime() ?: $file->getMTime() ?: $file->getATime();
if (!$timestamp) {
throw new \RuntimeException('Failed to get modified time');
}
if (now()->subDays(7)->gt(Carbon::createFromTimestamp($timestamp))) {
Log::info('Removed exported file ' . $file->getRealPath());
File::delete($file->getRealPath());
}
});
$zipFile = $this->makeZipFile($filesToZip, $dataFilePath);
$successMessage = "File exported in {$zipFile}.";
if (app()->runningInConsole()) {
$output = new \Symfony\Component\Console\Output\ConsoleOutput();
$output->writeln($successMessage);
}
Log::info($successMessage);
}
/**
* Make zip file and get its path.
*
* @param array $filesToZip
* @param string $dataFilePath
* @return string
* @throws \RuntimeException In case of failure.
*/
protected function makeZipFile(array $filesToZip, string $dataFilePath): string
{
File::ensureDirectoryExists($this->exportFolder);
$zip = new \ZipArchive();
$zipFileName = 'showroom-bo-export_' . now()->isoFormat('DD-MM-YYYY_HH-mm-ss') . '.zip';
$zipFilePath = "{$this->exportFolder}/{$zipFileName}";
if ($zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException("Failed to open zip file {$zipFilePath}");
}
foreach ($filesToZip as $file) {
if (! is_file($file)) {
throw new \RuntimeException("File to zip does not exist: {$file}");
}
$fileToAdd = Str::of($file)->replace($this->tempFolder, '')
->start('/')->explode('/')->filter()->implode('/');
if (! $zip->addFile($file, $fileToAdd)) {
throw new \RuntimeException("Failed to add file {$file} into zip");
}
}
if (! is_file($dataFilePath) || ! $zip->addFile($dataFilePath, basename($dataFilePath))) {
throw new \RuntimeException("Failed to add file {$dataFilePath} into zip");
}
if (! $zip->close()) {
throw new \RuntimeException(
"Failed to write zip {$zipFilePath}: " . $zip->getStatusString()
);
}
return $zipFilePath;
}
/**
* Assert schema is valid
*
* @param string $schemaFileName
* @param string $fileContent
* @return void
* @throws \Carsdotcom\JsonSchemaValidation\Exceptions\JsonSchemaValidationException In case of failure.
*/
protected function assertSchemaIsValid(string $schemaFileName, string $fileContent): void
{
SchemaValidator::validateEncodedStringOrThrow($fileContent, $schemaFileName);
}
/**
* Get associated files list to export
*
* @param array<string,array<int,\App\Models\Brand>|array<int,\App\Models\Font>> $data
* @return array{
* settings: string,
* brands: array<int, string>,
* fonts: array<int, string>,
* ranges: array<int, string>,
* products: array<int, string>
* }
*/
protected function getAssociatedFileList(array $data): array
{
$groupBy = fn(array $path) => Str::of(Arr::get($path, 0))->explode('/')->get(2);
$items = collect(Arr::get($data, 'brands'))->map(fn(Brand $brand) => [
$brand->picture,
// @phpstan-ignore-next-line
$brand->ranges->map(fn(Range $range) => [
$range->picture,
// @phpstan-ignore-next-line
$range->products->map(fn(Product $product) => [
$product->picture
])->collect()->groupBy($groupBy)
])->collect()->groupBy($groupBy)
])->collect();
/** @var string $logo */
$logo = Arr::get($this->getSettings(), 'logo');
/** @var array<string> $fonts */
$fonts = collect(Arr::get($data, 'fonts'))->pluck('font')->filter()->all();
/** @var \Illuminate\Support\Collection<int, string> $brandList */
$brandList = $items->pluck(0);
/** @var \Illuminate\Support\Collection<int, string> $rangeList */
$rangeList = $items->pluck(1)->map(fn(Collection $ranges) => $ranges
->map(fn(Collection $range) => $range->pluck(0)))->flatten();
/** @var \Illuminate\Support\Collection<int, string> $productList */
$productList = $items->pluck(1)->map(fn(Collection $ranges) => $ranges
->map(fn(Collection $range) => $range->pluck(1)))->flatten();
return [
'settings' => $logo,
'brands' => $brandList->all(),
'fonts' => $fonts,
'ranges' => $rangeList->all(),
'products' => $productList->all(),
];
}
/**
* Get export file content
*
* @param array<string,array<int,\App\Models\Brand>|array<int,\App\Models\Font>> $data
* @return array<'version'|'settings'|'fonts'|'items',string|array<string,number|string|boolean>|\App\Models\Font|\App\Models\Brand>
*/
protected function getFile(array $data): array
{
// phpcs:enable
$settings = $this->getSettings();
$tableName = $settings->getTable();
$settings->logo = Str::start(
\pathinfo($settings->logo, \PATHINFO_BASENAME),
"pictures/{$tableName}/"
);
return [
'version' => $this->getVersion(),
'settings' => Arr::except($settings->toArray(), ['id']),
'fonts' => Arr::map(Arr::get($data, 'fonts'), function (Font $font) {
$font->font = $font->font ? Str::start(
\pathinfo($font->font, \PATHINFO_BASENAME),
"fonts/"
) : $font->font;
return $font;
}),
'items' => Arr::map(Arr::get($data, 'brands'), function (Brand $brand) {
$this->setPictureExportPath($brand);
$brand->setRelation(
'ranges',
// @phpstan-ignore argument.type
$brand->ranges->map(function (Range $range) {
$this->setPictureExportPath($range);
// @phpstan-ignore argument.type
$range->setRelation('products', $range->products->map(function (Product $product) {
$this->setPictureExportPath($product);
return $product;
}));
return $range;
})
);
return $brand;
})
];
}
// * Private Methods
/**
* Get the files that should be zipped
*
* @phpcs:ignore
* @param array{settings: string,brands: array<int, string>,fonts: array<string>,ranges: array<int, string>,products: array<int, string>} $filesToZip
* @return array<string>
* @throws \RuntimeException If temp folder caanot be cleared in order to work.
*/
private function preparePicturesForZip(array $filesToZip): array
{
$newPaths = [];
if (!File::exists($this->tempFolder)) {
File::makeDirectory($this->tempFolder, 0700, true);
}
File::deleteDirectory($this->tempFolder, true);
if (!File::isEmptyDirectory($this->tempFolder)) {
throw new \RuntimeException("{$this->tempFolder} is not empty, refuse to prepare export");
}
$copyPath = fn(string $targetPath) => function (string $path) use ($targetPath): string {
$targetFilePath = Str::of($targetPath)->append('/')->append(\pathinfo($path, \PATHINFO_BASENAME));
File::copy(\public_path($path), $targetFilePath);
return $targetFilePath;
};
$toCopy = ['settings', 'brands', 'ranges', 'products'];
foreach ($toCopy as $element) {
$targetPath = Str::of($this->tempFolder)->append("/pictures/{$element}");
File::makeDirectory($targetPath, 0700, true);
$newPaths[$element] = collect($filesToZip[$element])->map($copyPath($targetPath))->all();
}
// * 'fonts'
$targetPath = Str::of($this->tempFolder)->append("/fonts");
File::makeDirectory($targetPath, 0700, true);
$newPaths['fonts'] = collect($filesToZip['fonts'])->map(function ($path) use ($targetPath) {
$targetFilePath = (string) $targetPath->append('/')->append(\pathinfo($path, \PATHINFO_BASENAME));
File::copy(\public_path($path), $targetFilePath);
return $targetFilePath;
})->all();
return collect($newPaths)->flatten()->all();
}
/**
* Set picture export path, where it will be stored in zip file.
*
* @param \App\Models\Brand|\App\Models\Range|\App\Models\Product $model
* @return void
*/
private function setPictureExportPath(Brand|Range|Product|Font &$model): void
{
$tableName = $model->getTable();
$model->picture = Str::start(
\pathinfo($model->picture, \PATHINFO_BASENAME),
"pictures/{$tableName}/"
);
}
/**
* Get app version
*
* @return string
*/
private function getVersion(): string
{
return Cache::get('composer')->version;
}
/**
* Get app settings
*
* @return \App\Models\Settings
*/
private function getSettings(): Settings
{
return Settings::getFromCache($this->tenantId);
}
/**
* Get app data
*
* @return array<'brands'|'fonts',array<int,\App\Models\Brand>|array<int,\App\Models\Font>>
*/
private function getData(): array
{
return [
'brands' => Brand::query()
->where([['status', true], ['tenant_id', $this->tenantId]])
->whereHas('ranges')
->with('ranges', function (Builder $q) {
return $q->where('status', true)
->whereHas('products')
->with('products', function (Builder $q) {
return $q->where('status', true);
});
})
->get()
->all(),
'fonts' => Font::query()
->where('tenant_id', $this->tenantId)
->get()
->all()
];
}
}