add: full multi-tenancy control

This commit is contained in:
Cauê Faleiros
2026-02-02 15:31:15 -03:00
commit c6ec92802b
1711 changed files with 258106 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
<?php
namespace Webkul\DataTransfer\Helpers;
class Error
{
/**
* Error Items.
*/
protected array $items = [];
/**
* Invalid rows.
*/
protected array $invalidRows = [];
/**
* Skipped rows.
*/
protected array $skippedRows = [];
/**
* Errors count.
*/
protected int $errorsCount = 0;
/**
* Error message template.
*/
protected array $messageTemplate = [];
/**
* Add error message template.
*/
public function addErrorMessage(string $code, string $template): self
{
$this->messageTemplate[$code] = $template;
return $this;
}
/**
* Add error message.
*/
public function addError(string $code, ?int $rowNumber = null, ?string $columnName = null, ?string $message = null): self
{
if ($this->isErrorAlreadyAdded($rowNumber, $code, $columnName)) {
return $this;
}
$this->addRowToInvalid($rowNumber);
$message = $this->getErrorMessage($code, $message, $columnName);
$this->items[$rowNumber][] = [
'code' => $code,
'column' => $columnName,
'message' => $message,
];
$this->errorsCount++;
return $this;
}
/**
* Check if error is already added for the row, code and column.
*/
public function isErrorAlreadyAdded(?int $rowNumber, string $code, ?string $columnName): bool
{
return collect($this->items[$rowNumber] ?? [])
->where('code', $code)
->where('column', $columnName)
->isNotEmpty();
}
/**
* Add specific row to invalid list via row number.
*/
protected function addRowToInvalid(?int $rowNumber): self
{
if (is_null($rowNumber)) {
return $this;
}
if (! in_array($rowNumber, $this->invalidRows)) {
$this->invalidRows[] = $rowNumber;
}
return $this;
}
/**
* Add specific row to invalid list via row number.
*/
public function addRowToSkip(?int $rowNumber): self
{
if (is_null($rowNumber)) {
return $this;
}
if (! in_array($rowNumber, $this->skippedRows)) {
$this->skippedRows[] = $rowNumber;
}
return $this;
}
/**
* Check if row is invalid by row number.
*/
public function isRowInvalid(int $rowNumber): bool
{
return in_array($rowNumber, array_merge($this->invalidRows, $this->skippedRows));
}
/**
* Build an error message via code, message and column name.
*/
protected function getErrorMessage(?string $code, ?string $message, ?string $columnName): string
{
if (
empty($message)
&& isset($this->messageTemplate[$code])
) {
$message = (string) $this->messageTemplate[$code];
}
if (
$columnName
&& $message
) {
$message = sprintf($message, $columnName);
}
if (! $message) {
$message = $code;
}
return $message;
}
/**
* Get number of invalid rows.
*/
public function getInvalidRowsCount(): int
{
return count($this->invalidRows);
}
/**
* Get current error count.
*/
public function getErrorsCount(): int
{
return $this->errorsCount;
}
/**
* Get all errors from an import process.
*/
public function getAllErrors(): array
{
return $this->items;
}
/**
* Return all errors grouped by code.
*/
public function getAllErrorsGroupedByCode(): array
{
$errors = [];
foreach ($this->items as $rowNumber => $rowErrors) {
foreach ($rowErrors as $error) {
if ($rowNumber === '') {
$errors[$error['code']][$error['message']] = null;
} else {
$errors[$error['code']][$error['message']][] = $rowNumber;
}
}
}
return $errors;
}
}

View File

@@ -0,0 +1,573 @@
<?php
namespace Webkul\DataTransfer\Helpers;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Csv;
use PhpOffice\PhpSpreadsheet\Writer\Xls;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Webkul\DataTransfer\Contracts\Import as ImportContract;
use Webkul\DataTransfer\Contracts\ImportBatch as ImportBatchContract;
use Webkul\DataTransfer\Helpers\Importers\AbstractImporter;
use Webkul\DataTransfer\Helpers\Sources\AbstractSource;
use Webkul\DataTransfer\Helpers\Sources\CSV as CSVSource;
use Webkul\DataTransfer\Helpers\Sources\Excel as ExcelSource;
use Webkul\DataTransfer\Repositories\ImportBatchRepository;
use Webkul\DataTransfer\Repositories\ImportRepository;
class Import
{
/**
* Import state for pending import.
*/
public const STATE_PENDING = 'pending';
/**
* Import state for validated import.
*/
public const STATE_VALIDATED = 'validated';
/**
* Import state for processing import.
*/
public const STATE_PROCESSING = 'processing';
/**
* Import state for processed import.
*/
public const STATE_PROCESSED = 'processed';
/**
* Import state for linking import.
*/
public const STATE_LINKING = 'linking';
/**
* Import state for linked import.
*/
public const STATE_LINKED = 'linked';
/**
* Import state for indexing import.
*/
public const STATE_INDEXING = 'indexing';
/**
* Import state for indexed import.
*/
public const STATE_INDEXED = 'indexed';
/**
* Import state for completed import.
*/
public const STATE_COMPLETED = 'completed';
/**
* Validation strategy for skipping the error during the import process.
*/
public const VALIDATION_STRATEGY_SKIP_ERRORS = 'skip-errors';
/**
* Validation strategy for stopping the import process on error.
*/
public const VALIDATION_STRATEGY_STOP_ON_ERROR = 'stop-on-errors';
/**
* Action constant for updating/creating for the resource.
*/
public const ACTION_APPEND = 'append';
/**
* Action constant for deleting the resource.
*/
public const ACTION_DELETE = 'delete';
/**
* Import instance.
*/
protected ImportContract $import;
/**
* Error helper instance.
*
* @var \Webkul\DataTransfer\Helpers\Error
*/
protected $typeImporter;
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(
protected ImportRepository $importRepository,
protected ImportBatchRepository $importBatchRepository,
protected Error $errorHelper
) {}
/**
* Set import instance.
*/
public function setImport(ImportContract $import): self
{
$this->import = $import;
return $this;
}
/**
* Returns import instance.
*/
public function getImport(): ImportContract
{
return $this->import;
}
/**
* Returns error helper instance.
*
* @return \Webkul\DataTransfer\Helpers\Error
*/
public function getErrorHelper()
{
return $this->errorHelper;
}
/**
* Returns source helper instance.
*/
public function getSource(): AbstractSource
{
if (Str::contains($this->import->file_path, '.csv')) {
$source = new CSVSource(
$this->import->file_path,
$this->import->field_separator,
);
} else {
$source = new ExcelSource(
$this->import->file_path,
$this->import->field_separator,
);
}
return $source;
}
/**
* Validates import and returns validation result.
*/
public function validate(): bool
{
try {
$source = $this->getSource();
$typeImporter = $this->getTypeImporter()->setSource($source);
$typeImporter->validateData();
} catch (\Exception $e) {
$this->errorHelper->addError(
AbstractImporter::ERROR_CODE_SYSTEM_EXCEPTION,
null,
null,
$e->getMessage()
);
}
$import = $this->importRepository->update([
'state' => self::STATE_VALIDATED,
'processed_rows_count' => $this->getProcessedRowsCount(),
'invalid_rows_count' => $this->errorHelper->getInvalidRowsCount(),
'errors_count' => $this->errorHelper->getErrorsCount(),
'errors' => $this->getFormattedErrors(),
'error_file_path' => $this->uploadErrorReport(),
], $this->import->id);
$this->setImport($import);
return $this->isValid();
}
/**
* Starts import process.
*/
public function isValid(): bool
{
if ($this->isErrorLimitExceeded()) {
return false;
}
if ($this->import->processed_rows_count <= $this->import->invalid_rows_count) {
return false;
}
return true;
}
/**
* Check if error limit has been exceeded.
*/
public function isErrorLimitExceeded(): bool
{
if (
$this->import->validation_strategy == self::VALIDATION_STRATEGY_STOP_ON_ERROR
&& $this->import->errors_count > $this->import->allowed_errors
) {
return true;
}
return false;
}
/**
* Starts import process.
*/
public function start(?ImportBatchContract $importBatch = null): bool
{
DB::beginTransaction();
try {
$typeImporter = $this->getTypeImporter();
$typeImporter->importData($importBatch);
} catch (\Exception $e) {
/**
* Rollback transaction.
*/
DB::rollBack();
throw $e;
} finally {
/**
* Commit transaction.
*/
DB::commit();
}
return true;
}
/**
* Link import resources.
*/
public function link(ImportBatchContract $importBatch): bool
{
DB::beginTransaction();
try {
$typeImporter = $this->getTypeImporter();
$typeImporter->linkData($importBatch);
} catch (\Exception $e) {
/**
* Rollback transaction.
*/
DB::rollBack();
throw $e;
} finally {
/**
* Commit transaction.
*/
DB::commit();
}
return true;
}
/**
* Index import resources.
*/
public function index(ImportBatchContract $importBatch): bool
{
DB::beginTransaction();
try {
$typeImporter = $this->getTypeImporter();
$typeImporter->indexData($importBatch);
} catch (\Exception $e) {
/**
* Rollback transaction.
*/
DB::rollBack();
throw $e;
} finally {
/**
* Commit transaction.
*/
DB::commit();
}
return true;
}
/**
* Started the import process.
*/
public function started(): void
{
$import = $this->importRepository->update([
'state' => self::STATE_PROCESSING,
'started_at' => now(),
'summary' => [],
], $this->import->id);
$this->setImport($import);
Event::dispatch('data_transfer.imports.started', $import);
}
/**
* Started the import linking process.
*/
public function linking(): void
{
$import = $this->importRepository->update([
'state' => self::STATE_LINKING,
], $this->import->id);
$this->setImport($import);
Event::dispatch('data_transfer.imports.linking', $import);
}
/**
* Started the import indexing process.
*/
public function indexing(): void
{
$import = $this->importRepository->update([
'state' => self::STATE_INDEXING,
], $this->import->id);
$this->setImport($import);
Event::dispatch('data_transfer.imports.indexing', $import);
}
/**
* Start the import process.
*/
public function completed(): void
{
$summary = $this->importBatchRepository
->select(
DB::raw('SUM(json_unquote(json_extract(summary, \'$."created"\'))) AS created'),
DB::raw('SUM(json_unquote(json_extract(summary, \'$."updated"\'))) AS updated'),
DB::raw('SUM(json_unquote(json_extract(summary, \'$."deleted"\'))) AS deleted'),
)
->where('import_id', $this->import->id)
->groupBy('import_id')
->first()
->toArray();
$import = $this->importRepository->update([
'state' => self::STATE_COMPLETED,
'summary' => $summary,
'completed_at' => now(),
], $this->import->id);
$this->setImport($import);
Event::dispatch('data_transfer.imports.completed', $import);
}
/**
* Returns import stats.
*/
public function stats(string $state): array
{
$total = $this->import->batches->count();
$completed = $this->import->batches->where('state', $state)->count();
$progress = $total
? round($completed / $total * 100)
: 0;
$summary = $this->importBatchRepository
->select(
DB::raw('SUM(json_unquote(json_extract(summary, \'$."created"\'))) AS created'),
DB::raw('SUM(json_unquote(json_extract(summary, \'$."updated"\'))) AS updated'),
DB::raw('SUM(json_unquote(json_extract(summary, \'$."deleted"\'))) AS deleted'),
)
->where('import_id', $this->import->id)
->where('state', $state)
->groupBy('import_id')
->first()
?->toArray();
return [
'batches' => [
'total' => $total,
'completed' => $completed,
'remaining' => $total - $completed,
],
'progress' => $progress,
'summary' => $summary ?? [
'created' => 0,
'updated' => 0,
'deleted' => 0,
],
];
}
/**
* Return all error grouped by error code.
*/
public function getFormattedErrors(): array
{
$errors = [];
foreach ($this->errorHelper->getAllErrorsGroupedByCode() as $groupedErrors) {
foreach ($groupedErrors as $errorMessage => $rowNumbers) {
if (! empty($rowNumbers)) {
$errors[] = 'Row(s) '.implode(', ', $rowNumbers).': '.$errorMessage;
} else {
$errors[] = $errorMessage;
}
}
}
return $errors;
}
/**
* Uploads error report and save the path to the database.
*/
public function uploadErrorReport(): ?string
{
/**
* Return null if there are no errors.
*/
if (! $this->errorHelper->getErrorsCount()) {
return null;
}
/**
* Return null if there are no invalid rows.
*/
if (! $this->errorHelper->getInvalidRowsCount()) {
return null;
}
$errors = $this->errorHelper->getAllErrors();
$source = $this->getTypeImporter()->getSource();
$source->rewind();
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
/**
* Add headers with extra error column.
*/
$sheet->fromArray(
[array_merge($source->getColumnNames(), [
'error',
])],
null,
'A1'
);
$rowNumber = 2;
while ($source->valid()) {
try {
$rowData = $source->current();
} catch (\InvalidArgumentException $e) {
$source->next();
continue;
}
$rowErrors = $errors[$source->getCurrentRowNumber()] ?? [];
if (! empty($rowErrors)) {
$rowErrors = Arr::pluck($rowErrors, 'message');
}
$rowData[] = implode('|', $rowErrors);
$sheet->fromArray([$rowData], null, 'A'.$rowNumber++);
$source->next();
}
$fileType = pathinfo($this->import->file_path, PATHINFO_EXTENSION);
switch ($fileType) {
case 'csv':
$writer = new Csv($spreadsheet);
$writer->setDelimiter($this->import->field_separator);
break;
case 'xls':
$writer = new Xls($spreadsheet);
case 'xlsx':
$writer = new Xlsx($spreadsheet);
break;
default:
throw new \InvalidArgumentException("Unsupported file type: $fileType");
}
$errorFilePath = 'imports/'.time().'-error-report.'.$fileType;
$writer->save(Storage::disk('public')->path($errorFilePath));
return $errorFilePath;
}
/**
* Validates source file and returns validation result.
*/
public function getTypeImporter(): AbstractImporter
{
if (! $this->typeImporter) {
$importerConfig = config('importers.'.$this->import->type);
$this->typeImporter = app()->make($importerConfig['importer'])
->setImport($this->import)
->setErrorHelper($this->errorHelper);
}
return $this->typeImporter;
}
/**
* Returns number of checked rows.
*/
public function getProcessedRowsCount(): int
{
return $this->getTypeImporter()->getProcessedRowsCount();
}
/**
* Is linking resource required for the import operation.
*/
public function isLinkingRequired(): bool
{
return $this->getTypeImporter()->isLinkingRequired();
}
/**
* Is indexing resource required for the import operation.
*/
public function isIndexingRequired(): bool
{
return $this->getTypeImporter()->isIndexingRequired();
}
}

View File

@@ -0,0 +1,551 @@
<?php
namespace Webkul\DataTransfer\Helpers\Importers;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use Webkul\Attribute\Repositories\AttributeRepository;
use Webkul\Attribute\Repositories\AttributeValueRepository;
use Webkul\Core\Contracts\Validations\Decimal;
use Webkul\DataTransfer\Contracts\Import as ImportContract;
use Webkul\DataTransfer\Contracts\ImportBatch as ImportBatchContract;
use Webkul\DataTransfer\Helpers\Import;
use Webkul\DataTransfer\Jobs\Import\Completed as CompletedJob;
use Webkul\DataTransfer\Jobs\Import\ImportBatch as ImportBatchJob;
use Webkul\DataTransfer\Jobs\Import\IndexBatch as IndexBatchJob;
use Webkul\DataTransfer\Jobs\Import\Indexing as IndexingJob;
use Webkul\DataTransfer\Jobs\Import\LinkBatch as LinkBatchJob;
use Webkul\DataTransfer\Jobs\Import\Linking as LinkingJob;
use Webkul\DataTransfer\Repositories\ImportBatchRepository;
abstract class AbstractImporter
{
/**
* Error code for system exception.
*/
public const ERROR_CODE_SYSTEM_EXCEPTION = 'system_exception';
/**
* Error code for column not found.
*/
public const ERROR_CODE_COLUMN_NOT_FOUND = 'column_not_found';
/**
* Error code for column empty header.
*/
public const ERROR_CODE_COLUMN_EMPTY_HEADER = 'column_empty_header';
/**
* Error code for column name invalid.
*/
public const ERROR_CODE_COLUMN_NAME_INVALID = 'column_name_invalid';
/**
* Error code for invalid attribute.
*/
public const ERROR_CODE_INVALID_ATTRIBUTE = 'invalid_attribute_name';
/**
* Error code for wrong quotes.
*/
public const ERROR_CODE_WRONG_QUOTES = 'wrong_quotes';
/**
* Error code for wrong columns number.
*/
public const ERROR_CODE_COLUMNS_NUMBER = 'wrong_columns_number';
/**
* Error message templates.
*/
protected array $errorMessages = [
self::ERROR_CODE_SYSTEM_EXCEPTION => 'data_transfer::app.validation.errors.system',
self::ERROR_CODE_COLUMN_NOT_FOUND => 'data_transfer::app.validation.errors.column-not-found',
self::ERROR_CODE_COLUMN_EMPTY_HEADER => 'data_transfer::app.validation.errors.column-empty-headers',
self::ERROR_CODE_COLUMN_NAME_INVALID => 'data_transfer::app.validation.errors.column-name-invalid',
self::ERROR_CODE_INVALID_ATTRIBUTE => 'data_transfer::app.validation.errors.invalid-attribute',
self::ERROR_CODE_WRONG_QUOTES => 'data_transfer::app.validation.errors.wrong-quotes',
self::ERROR_CODE_COLUMNS_NUMBER => 'data_transfer::app.validation.errors.column-numbers',
];
public const BATCH_SIZE = 100;
/**
* Is linking required.
*/
protected bool $linkingRequired = false;
/**
* Is indexing required.
*/
protected bool $indexingRequired = false;
/**
* Error helper instance.
*
* @var \Webkul\DataTransfer\Helpers\Error
*/
protected $errorHelper;
/**
* Import instance.
*/
protected ImportContract $import;
/**
* Source instance.
*
* @var \Webkul\DataTransfer\Helpers\Source
*/
protected $source;
/**
* Valid column names.
*/
protected array $validColumnNames = [];
/**
* Array of numbers of validated rows as keys and boolean TRUE as values.
*/
protected array $validatedRows = [];
/**
* Number of rows processed by validation.
*/
protected int $processedRowsCount = 0;
/**
* Number of created items.
*/
protected int $createdItemsCount = 0;
/**
* Number of updated items.
*/
protected int $updatedItemsCount = 0;
/**
* Number of deleted items.
*/
protected int $deletedItemsCount = 0;
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(
protected ImportBatchRepository $importBatchRepository,
protected AttributeRepository $attributeRepository,
protected AttributeValueRepository $attributeValueRepository
) {}
/**
* Validate data row.
*/
abstract public function validateRow(array $rowData, int $rowNumber): bool;
/**
* Import data rows.
*/
abstract public function importBatch(ImportBatchContract $importBatchContract): bool;
/**
* Initialize Product error messages.
*/
protected function initErrorMessages(): void
{
foreach ($this->errorMessages as $errorCode => $message) {
$this->errorHelper->addErrorMessage($errorCode, trans($message));
}
}
/**
* Import instance.
*/
public function setImport(ImportContract $import): self
{
$this->import = $import;
return $this;
}
/**
* Import instance.
*
* @param \Webkul\DataTransfer\Helpers\Source $errorHelper
*/
public function setSource($source)
{
$this->source = $source;
return $this;
}
/**
* Import instance.
*
* @param \Webkul\DataTransfer\Helpers\Error $errorHelper
*/
public function setErrorHelper($errorHelper): self
{
$this->errorHelper = $errorHelper;
$this->initErrorMessages();
return $this;
}
/**
* Import instance.
*
* @return \Webkul\DataTransfer\Helpers\Source
*/
public function getSource()
{
return $this->source;
}
/**
* Retrieve valid column names.
*/
public function getValidColumnNames(): array
{
return $this->validColumnNames;
}
/**
* Validate data.
*/
public function validateData(): void
{
Event::dispatch('data_transfer.imports.validate.before', $this->import);
$errors = [];
$absentColumns = array_diff($this->permanentAttributes, $columns = $this->getSource()->getColumnNames());
if (! empty($absentColumns)) {
$errors[self::ERROR_CODE_COLUMN_NOT_FOUND] = $absentColumns;
}
foreach ($columns as $columnNumber => $columnName) {
if (empty($columnName)) {
$errors[self::ERROR_CODE_COLUMN_EMPTY_HEADER][] = $columnNumber + 1;
} elseif (! preg_match('/^[a-z][a-z0-9_]*$/', $columnName)) {
$errors[self::ERROR_CODE_COLUMN_NAME_INVALID][] = $columnName;
} elseif (! in_array($columnName, $this->getValidColumnNames())) {
$errors[self::ERROR_CODE_INVALID_ATTRIBUTE][] = $columnName;
}
}
/**
* Add Columns Errors.
*/
foreach ($errors as $errorCode => $error) {
$this->addErrors($errorCode, $error);
}
if (! $this->errorHelper->getErrorsCount()) {
$this->saveValidatedBatches();
}
Event::dispatch('data_transfer.imports.validate.after', $this->import);
}
/**
* Save validated batches.
*/
protected function saveValidatedBatches(): self
{
$source = $this->getSource();
$batchRows = [];
$source->rewind();
/**
* Clean previous saved batches.
*/
$this->importBatchRepository->deleteWhere([
'import_id' => $this->import->id,
]);
while (
$source->valid()
|| count($batchRows)
) {
if (
count($batchRows) == self::BATCH_SIZE
|| ! $source->valid()
) {
$this->importBatchRepository->create([
'import_id' => $this->import->id,
'data' => $batchRows,
]);
$batchRows = [];
}
if ($source->valid()) {
$rowData = $source->current();
if ($this->validateRow($rowData, $source->getCurrentRowNumber())) {
$batchRows[] = $this->prepareRowForDb($rowData);
}
$this->processedRowsCount++;
$source->next();
}
}
return $this;
}
/**
* Prepare validation rules.
*/
public function getValidationRules(string $entityType, array $rowData): array
{
if (empty($entityType)) {
return [];
}
$rules = [];
$attributes = $this->attributeRepository->scopeQuery(fn ($query) => $query->whereIn('code', array_keys($rowData))->where('entity_type', $entityType))->get();
foreach ($attributes as $attribute) {
$validations = [];
if ($attribute->type == 'boolean') {
continue;
} elseif ($attribute->type == 'address') {
if (! $attribute->is_required) {
continue;
}
$validations = [
$attribute->code.'.address' => 'required',
$attribute->code.'.country' => 'required',
$attribute->code.'.state' => 'required',
$attribute->code.'.city' => 'required',
$attribute->code.'.postcode' => 'required',
];
} elseif ($attribute->type == 'email') {
$validations = [
$attribute->code => [$attribute->is_required ? 'required' : 'nullable'],
$attribute->code.'.*.value' => [$attribute->is_required ? 'required' : 'nullable', 'email'],
$attribute->code.'.*.label' => $attribute->is_required ? 'required' : 'nullable',
];
} elseif ($attribute->type == 'phone') {
$validations = [
$attribute->code => [$attribute->is_required ? 'required' : 'nullable'],
$attribute->code.'.*.value' => [$attribute->is_required ? 'required' : 'nullable'],
$attribute->code.'.*.label' => $attribute->is_required ? 'required' : 'nullable',
];
} else {
$validations[$attribute->code] = [$attribute->is_required ? 'required' : 'nullable'];
if ($attribute->type == 'text' && $attribute->validation) {
array_push($validations[$attribute->code],
$attribute->validation == 'decimal'
? new Decimal
: $attribute->validation
);
}
if ($attribute->type == 'price') {
array_push($validations[$attribute->code], new Decimal);
}
}
if ($attribute->is_unique) {
array_push($validations[in_array($attribute->type, ['email', 'phone'])
? $attribute->code.'.*.value'
: $attribute->code
], function ($field, $value, $fail) use ($attribute) {
if (! $this->attributeValueRepository->isValueUnique(null, $attribute->entity_type, $attribute, $field)) {
$fail(trans('data_transfer::app.validation.errors.already-exists', ['attribute' => $attribute->name]));
}
});
}
$rules = [
...$rules,
...$validations,
];
}
return $rules;
}
/**
* Start the import process.
*/
public function importData(?ImportBatchContract $importBatch = null): bool
{
if ($importBatch) {
$this->importBatch($importBatch);
return true;
}
$typeBatches = [];
foreach ($this->import->batches as $batch) {
$typeBatches['import'][] = new ImportBatchJob($batch);
if ($this->isLinkingRequired()) {
$typeBatches['link'][] = new LinkBatchJob($batch);
}
if ($this->isIndexingRequired()) {
$typeBatches['index'][] = new IndexBatchJob($batch);
}
}
$chain[] = Bus::batch($typeBatches['import']);
if (! empty($typeBatches['link'])) {
$chain[] = new LinkingJob($this->import);
$chain[] = Bus::batch($typeBatches['link']);
}
if (! empty($typeBatches['index'])) {
$chain[] = new IndexingJob($this->import);
$chain[] = Bus::batch($typeBatches['index']);
}
$chain[] = new CompletedJob($this->import);
Bus::chain($chain)->dispatch();
return true;
}
/**
* Link resource data.
*/
public function linkData(ImportBatchContract $importBatch): bool
{
$this->linkBatch($importBatch);
return true;
}
/**
* Index resource data.
*/
public function indexData(ImportBatchContract $importBatch): bool
{
$this->indexBatch($importBatch);
return true;
}
/**
* Add errors to error aggregator.
*/
protected function addErrors(string $code, mixed $errors): void
{
$this->errorHelper->addError(
$code,
null,
implode('", "', $errors)
);
}
/**
* Add row as skipped.
*
* @param int|null $rowNumber
* @param string|null $columnName
* @param string|null $errorMessage
* @return $this
*/
protected function skipRow($rowNumber, string $errorCode, $columnName = null, $errorMessage = null): self
{
$this->errorHelper->addError(
$errorCode,
$rowNumber,
$columnName,
$errorMessage
);
$this->errorHelper->addRowToSkip($rowNumber);
return $this;
}
/**
* Prepare row data to save into the database.
*/
protected function prepareRowForDb(array $rowData): array
{
$rowData = array_map(function ($value) {
return $value === '' ? null : $value;
}, $rowData);
return $rowData;
}
/**
* Returns number of checked rows.
*/
public function getProcessedRowsCount(): int
{
return $this->processedRowsCount;
}
/**
* Returns number of created items count.
*/
public function getCreatedItemsCount(): int
{
return $this->createdItemsCount;
}
/**
* Returns number of updated items count.
*/
public function getUpdatedItemsCount(): int
{
return $this->updatedItemsCount;
}
/**
* Returns number of deleted items count.
*/
public function getDeletedItemsCount(): int
{
return $this->deletedItemsCount;
}
/**
* Is linking resource required for the import operation.
*/
public function isLinkingRequired(): bool
{
if ($this->import->action == Import::ACTION_DELETE) {
return false;
}
return $this->linkingRequired;
}
/**
* Is indexing resource required for the import operation.
*/
public function isIndexingRequired(): bool
{
if ($this->import->action == Import::ACTION_DELETE) {
return false;
}
return $this->indexingRequired;
}
}

View File

@@ -0,0 +1,515 @@
<?php
namespace Webkul\DataTransfer\Helpers\Importers\Leads;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Validator;
use Webkul\Attribute\Repositories\AttributeRepository;
use Webkul\Attribute\Repositories\AttributeValueRepository;
use Webkul\Core\Contracts\Validations\Decimal;
use Webkul\DataTransfer\Contracts\ImportBatch as ImportBatchContract;
use Webkul\DataTransfer\Helpers\Import;
use Webkul\DataTransfer\Helpers\Importers\AbstractImporter;
use Webkul\DataTransfer\Repositories\ImportBatchRepository;
use Webkul\Lead\Repositories\LeadRepository;
use Webkul\Lead\Repositories\ProductRepository as LeadProductRepository;
class Importer extends AbstractImporter
{
/**
* Error code for non existing id.
*/
const ERROR_ID_NOT_FOUND_FOR_DELETE = 'id_not_found_to_delete';
/**
* Permanent entity columns.
*/
protected array $validColumnNames = [
'id',
'title',
'description',
'lead_value',
'status',
'lost_reason',
'closed_at',
'user_id',
'person_id',
'lead_source_id',
'lead_type_id',
'lead_pipeline_id',
'lead_pipeline_stage_id',
'expected_close_date',
'product',
];
/**
* Error message templates.
*/
protected array $messages = [
self::ERROR_ID_NOT_FOUND_FOR_DELETE => 'data_transfer::app.importers.leads.validation.errors.id-not-found',
];
/**
* Permanent entity columns.
*
* @var string[]
*/
protected $permanentAttributes = ['title'];
/**
* Permanent entity column.
*/
protected string $masterAttributeCode = 'id';
/**
* Is linking required
*/
protected bool $linkingRequired = true;
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(
protected ImportBatchRepository $importBatchRepository,
protected LeadRepository $leadRepository,
protected LeadProductRepository $leadProductRepository,
protected AttributeRepository $attributeRepository,
protected AttributeValueRepository $attributeValueRepository,
protected Storage $leadsStorage,
) {
parent::__construct(
$importBatchRepository,
$attributeRepository,
$attributeValueRepository,
);
}
/**
* Initialize leads error templates.
*/
protected function initErrorMessages(): void
{
foreach ($this->messages as $errorCode => $message) {
$this->errorHelper->addErrorMessage($errorCode, trans($message));
}
parent::initErrorMessages();
}
/**
* Validate data.
*/
public function validateData(): void
{
$this->leadsStorage->init();
parent::validateData();
}
/**
* Validates row.
*/
public function validateRow(array $rowData, int $rowNumber): bool
{
/**
* If row is already validated than no need for further validation.
*/
if (isset($this->validatedRows[$rowNumber])) {
return ! $this->errorHelper->isRowInvalid($rowNumber);
}
$this->validatedRows[$rowNumber] = true;
/**
* If import action is delete than no need for further validation.
*/
if ($this->import->action == Import::ACTION_DELETE) {
if (! $this->isTitleExist($rowData['title'])) {
$this->skipRow($rowNumber, self::ERROR_ID_NOT_FOUND_FOR_DELETE, 'id');
return false;
}
return true;
}
if (! empty($rowData['product'])) {
$product = $this->parseProducts($rowData['product']);
$validator = Validator::make($product, [
'id' => 'required|exists:products,id',
'price' => 'required',
'quantity' => 'required',
]);
if ($validator->fails()) {
$failedAttributes = $validator->failed();
foreach ($validator->errors()->getMessages() as $attributeCode => $message) {
$errorCode = array_key_first($failedAttributes[$attributeCode] ?? []);
$this->skipRow($rowNumber, $errorCode, $attributeCode, current($message));
}
}
}
/**
* Validate leads attributes.
*/
$validator = Validator::make($rowData, [
...$this->getValidationRules('leads|persons', $rowData),
'id' => 'numeric',
'status' => 'sometimes|required|in:0,1',
'user_id' => 'required|exists:users,id',
'person_id' => 'required|exists:persons,id',
'lead_source_id' => 'required|exists:lead_sources,id',
'lead_type_id' => 'required|exists:lead_types,id',
'lead_pipeline_id' => 'required|exists:lead_pipelines,id',
'lead_pipeline_stage_id' => 'required|exists:lead_pipeline_stages,id',
]);
if ($validator->fails()) {
$failedAttributes = $validator->failed();
foreach ($validator->errors()->getMessages() as $attributeCode => $message) {
$errorCode = array_key_first($failedAttributes[$attributeCode] ?? []);
$this->skipRow($rowNumber, $errorCode, $attributeCode, current($message));
}
}
return ! $this->errorHelper->isRowInvalid($rowNumber);
}
/**
* Prepare row data for lead product.
*/
protected function parseProducts(?string $products): array
{
$productData = [];
$productArray = explode(',', $products);
foreach ($productArray as $product) {
if (empty($product)) {
continue;
}
[$key, $value] = explode('=', $product);
$productData[$key] = $value;
}
if (
isset($productData['price'])
&& isset($productData['quantity'])
) {
$productData['amount'] = $productData['price'] * $productData['quantity'];
}
return $productData;
}
/**
* Get validation rules.
*/
public function getValidationRules(string $entityTypes, array $rowData): array
{
$rules = [];
foreach (explode('|', $entityTypes) as $entityType) {
$attributes = $this->attributeRepository->scopeQuery(fn ($query) => $query->whereIn('code', array_keys($rowData))->where('entity_type', $entityType))->get();
foreach ($attributes as $attribute) {
if ($entityType == 'persons') {
$attribute->code = 'person.'.$attribute->code;
}
$validations = [];
if ($attribute->type == 'boolean') {
continue;
} elseif ($attribute->type == 'address') {
if (! $attribute->is_required) {
continue;
}
$validations = [
$attribute->code.'.address' => 'required',
$attribute->code.'.country' => 'required',
$attribute->code.'.state' => 'required',
$attribute->code.'.city' => 'required',
$attribute->code.'.postcode' => 'required',
];
} elseif ($attribute->type == 'email') {
$validations = [
$attribute->code => [$attribute->is_required ? 'required' : 'nullable'],
$attribute->code.'.*.value' => [$attribute->is_required ? 'required' : 'nullable', 'email'],
$attribute->code.'.*.label' => $attribute->is_required ? 'required' : 'nullable',
];
} elseif ($attribute->type == 'phone') {
$validations = [
$attribute->code => [$attribute->is_required ? 'required' : 'nullable'],
$attribute->code.'.*.value' => [$attribute->is_required ? 'required' : 'nullable'],
$attribute->code.'.*.label' => $attribute->is_required ? 'required' : 'nullable',
];
} else {
$validations[$attribute->code] = [$attribute->is_required ? 'required' : 'nullable'];
if ($attribute->type == 'text' && $attribute->validation) {
array_push($validations[$attribute->code],
$attribute->validation == 'decimal'
? new Decimal
: $attribute->validation
);
}
if ($attribute->type == 'price') {
array_push($validations[$attribute->code], new Decimal);
}
}
if ($attribute->is_unique) {
array_push($validations[in_array($attribute->type, ['email', 'phone'])
? $attribute->code.'.*.value'
: $attribute->code
], function ($field, $value, $fail) use ($attribute) {
if (! $this->attributeValueRepository->isValueUnique(
null,
$attribute->entity_type,
$attribute,
request($field)
)
) {
$fail(trans('data_transfer::app.validation.errors.already-exists', ['attribute' => $attribute->name]));
}
});
}
$rules = [
...$rules,
...$validations,
];
}
}
return $rules;
}
/**
* Start the import process.
*/
public function importBatch(ImportBatchContract $batch): bool
{
Event::dispatch('data_transfer.imports.batch.import.before', $batch);
if ($batch->import->action == Import::ACTION_DELETE) {
$this->deleteLeads($batch);
} else {
$this->saveLeads($batch);
}
/**
* Update import batch summary.
*/
$batch = $this->importBatchRepository->update([
'state' => Import::STATE_PROCESSED,
'summary' => [
'created' => $this->getCreatedItemsCount(),
'updated' => $this->getUpdatedItemsCount(),
'deleted' => $this->getDeletedItemsCount(),
],
], $batch->id);
Event::dispatch('data_transfer.imports.batch.import.after', $batch);
return true;
}
/**
* Start the products linking process
*/
public function linkBatch(ImportBatchContract $batch): bool
{
Event::dispatch('data_transfer.imports.batch.linking.before', $batch);
/**
* Load leads storage with batch ids.
*/
$this->leadsStorage->load(Arr::pluck($batch->data, 'title'));
$products = [];
foreach ($batch->data as $rowData) {
/**
* Prepare products.
*/
$this->prepareProducts($rowData, $products);
}
$this->saveProducts($products);
/**
* Update import batch summary
*/
$this->importBatchRepository->update([
'state' => Import::STATE_LINKED,
], $batch->id);
Event::dispatch('data_transfer.imports.batch.linking.after', $batch);
return true;
}
/**
* Prepare products.
*/
public function prepareProducts($rowData, &$product): void
{
if (! empty($rowData['product'])) {
$product[$rowData['title']] = $this->parseProducts($rowData['product']);
}
}
/**
* Save products.
*/
public function saveProducts(array $products): void
{
$leadProducts = [];
foreach ($products as $title => $product) {
$lead = $this->leadsStorage->get($title);
$leadProducts['insert'][] = [
'lead_id' => $lead['id'],
'product_id' => $product['id'],
'price' => $product['price'],
'quantity' => $product['quantity'],
'amount' => $product['amount'],
];
}
foreach ($leadProducts['insert'] as $key => $leadProduct) {
$this->leadProductRepository->deleteWhere([
'lead_id' => $leadProduct['lead_id'],
'product_id' => $leadProduct['product_id'],
]);
}
$this->leadProductRepository->upsert($leadProducts['insert'], ['lead_id', 'product_id']);
}
/**
* Delete leads from current batch.
*/
protected function deleteLeads(ImportBatchContract $batch): bool
{
/**
* Load leads storage with batch ids.
*/
$this->leadsStorage->load(Arr::pluck($batch->data, 'title'));
$idsToDelete = [];
foreach ($batch->data as $rowData) {
if (! $this->isTitleExist($rowData['title'])) {
continue;
}
$idsToDelete[] = $this->leadsStorage->get($rowData['title']);
}
$idsToDelete = array_unique($idsToDelete);
$this->deletedItemsCount = count($idsToDelete);
$this->leadRepository->deleteWhere([['id', 'IN', $idsToDelete]]);
return true;
}
/**
* Save leads from current batch.
*/
protected function saveLeads(ImportBatchContract $batch): bool
{
/**
* Load lead storage with batch unique title.
*/
$this->leadsStorage->load(Arr::pluck($batch->data, 'title'));
$leads = [];
/**
* Prepare leads for import.
*/
foreach ($batch->data as $rowData) {
if (isset($rowData['id'])) {
$leads['update'][$rowData['id']] = Arr::except($rowData, ['product']);
} else {
$leads['insert'][$rowData['title']] = [
...Arr::except($rowData, ['id', 'product']),
'created_at' => $rowData['created_at'] ?? now(),
'updated_at' => $rowData['updated_at'] ?? now(),
];
}
}
if (! empty($leads['update'])) {
$this->updatedItemsCount += count($leads['update']);
$this->leadRepository->upsert(
$leads['update'],
$this->masterAttributeCode
);
}
if (! empty($leads['insert'])) {
$this->createdItemsCount += count($leads['insert']);
$this->leadRepository->insert($leads['insert']);
/**
* Update the sku storage with newly created products
*/
$newLeads = $this->leadRepository->findWhereIn(
'title',
array_keys($leads['insert']),
[
'id',
'title',
]
);
foreach ($newLeads as $lead) {
$this->leadsStorage->set($lead->title, [
'id' => $lead->id,
'title' => $lead->title,
]);
}
}
return true;
}
/**
* Check if title exists.
*/
public function isTitleExist(string $title): bool
{
return $this->leadsStorage->has($title);
}
/**
* Prepare row data to save into the database.
*/
protected function prepareRowForDb(array $rowData): array
{
return parent::prepareRowForDb($rowData);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Webkul\DataTransfer\Helpers\Importers\Leads;
use Webkul\Lead\Repositories\LeadRepository;
class Storage
{
/**
* Items contains identifier as key and product information as value.
*/
protected array $items = [];
/**
* Columns which will be selected from database.
*/
protected array $selectColumns = ['id', 'title'];
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(protected LeadRepository $leadRepository) {}
/**
* Initialize storage.
*/
public function init(): void
{
$this->items = [];
$this->load();
}
/**
* Load the leads.
*/
public function load(array $titles = []): void
{
if (empty($titles)) {
$leads = $this->leadRepository->all($this->selectColumns);
} else {
$leads = $this->leadRepository->findWhereIn('title', $titles, $this->selectColumns);
}
foreach ($leads as $lead) {
$this->set($lead->title, [
'id' => $lead->id,
'title' => $lead->title,
]);
}
}
/**
* Get Ids and Unique Id.
*/
public function set(string $title, array $data): self
{
$this->items[$title] = $data;
return $this;
}
/**
* Check if unique id exists.
*/
public function has(string $title): bool
{
return isset($this->items[$title]);
}
/**
* Get unique id information.
*/
public function get(string $title): ?array
{
if (! $this->has($title)) {
return null;
}
return $this->items[$title];
}
public function getItems(): array
{
return $this->items;
}
/**
* Is storage is empty.
*/
public function isEmpty(): bool
{
return empty($this->items);
}
}

View File

@@ -0,0 +1,492 @@
<?php
namespace Webkul\DataTransfer\Helpers\Importers\Persons;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Validator;
use Webkul\Attribute\Repositories\AttributeRepository;
use Webkul\Attribute\Repositories\AttributeValueRepository;
use Webkul\Contact\Repositories\PersonRepository;
use Webkul\DataTransfer\Contracts\ImportBatch as ImportBatchContract;
use Webkul\DataTransfer\Helpers\Import;
use Webkul\DataTransfer\Helpers\Importers\AbstractImporter;
use Webkul\DataTransfer\Repositories\ImportBatchRepository;
class Importer extends AbstractImporter
{
/**
* Error code for non existing email.
*/
const ERROR_EMAIL_NOT_FOUND_FOR_DELETE = 'email_not_found_to_delete';
/**
* Error code for duplicated email.
*/
const ERROR_DUPLICATE_EMAIL = 'duplicated_email';
/**
* Error code for duplicated phone.
*/
const ERROR_DUPLICATE_PHONE = 'duplicated_phone';
/**
* Permanent entity columns.
*/
protected array $validColumnNames = [
'contact_numbers',
'emails',
'job_title',
'name',
'organization_id',
'user_id',
];
/**
* Error message templates.
*/
protected array $messages = [
self::ERROR_EMAIL_NOT_FOUND_FOR_DELETE => 'data_transfer::app.importers.persons.validation.errors.email-not-found',
self::ERROR_DUPLICATE_EMAIL => 'data_transfer::app.importers.persons.validation.errors.duplicate-email',
self::ERROR_DUPLICATE_PHONE => 'data_transfer::app.importers.persons.validation.errors.duplicate-phone',
];
/**
* Permanent entity columns.
*
* @var string[]
*/
protected $permanentAttributes = ['emails'];
/**
* Permanent entity column.
*/
protected string $masterAttributeCode = 'unique_id';
/**
* Emails storage.
*/
protected array $emails = [];
/**
* Phones storage.
*/
protected array $phones = [];
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(
protected ImportBatchRepository $importBatchRepository,
protected PersonRepository $personRepository,
protected AttributeRepository $attributeRepository,
protected AttributeValueRepository $attributeValueRepository,
protected Storage $personStorage,
) {
parent::__construct(
$importBatchRepository,
$attributeRepository,
$attributeValueRepository,
);
}
/**
* Initialize Product error templates.
*/
protected function initErrorMessages(): void
{
foreach ($this->messages as $errorCode => $message) {
$this->errorHelper->addErrorMessage($errorCode, trans($message));
}
parent::initErrorMessages();
}
/**
* Validate data.
*/
public function validateData(): void
{
$this->personStorage->init();
parent::validateData();
}
/**
* Validates row.
*/
public function validateRow(array $rowData, int $rowNumber): bool
{
$rowData = $this->parsedRowData($rowData);
/**
* If row is already validated than no need for further validation.
*/
if (isset($this->validatedRows[$rowNumber])) {
return ! $this->errorHelper->isRowInvalid($rowNumber);
}
$this->validatedRows[$rowNumber] = true;
/**
* If import action is delete than no need for further validation.
*/
if ($this->import->action == Import::ACTION_DELETE) {
foreach ($rowData['emails'] as $email) {
if (! $this->isEmailExist($email['value'])) {
$this->skipRow($rowNumber, self::ERROR_EMAIL_NOT_FOUND_FOR_DELETE, 'email');
return false;
}
return true;
}
}
/**
* Validate row data.
*/
$validator = Validator::make($rowData, [
...$this->getValidationRules('persons', $rowData),
'organization_id' => 'required|exists:organizations,id',
'user_id' => 'required|exists:users,id',
'contact_numbers' => 'required|array',
'contact_numbers.*.value' => 'required|numeric',
'contact_numbers.*.label' => 'required|in:home,work',
'emails' => 'required|array',
'emails.*.value' => 'required|email',
'emails.*.label' => 'required|in:home,work',
]);
if ($validator->fails()) {
$failedAttributes = $validator->failed();
foreach ($validator->errors()->getMessages() as $attributeCode => $message) {
$errorCode = array_key_first($failedAttributes[$attributeCode] ?? []);
$this->skipRow($rowNumber, $errorCode, $attributeCode, current($message));
}
}
/**
* Check if email is unique.
*/
if (! empty($emails = $rowData['emails'])) {
foreach ($emails as $email) {
if (! in_array($email['value'], $this->emails)) {
$this->emails[] = $email['value'];
} else {
$message = sprintf(
trans($this->messages[self::ERROR_DUPLICATE_EMAIL]),
$email['value']
);
$this->skipRow($rowNumber, self::ERROR_DUPLICATE_EMAIL, 'email', $message);
}
}
}
/**
* Check if phone(s) are unique.
*/
if (! empty($rowData['contact_numbers'])) {
foreach ($rowData['contact_numbers'] as $phone) {
if (! in_array($phone['value'], $this->phones)) {
if (! empty($phone['value'])) {
$this->phones[] = $phone['value'];
}
} else {
$message = sprintf(
trans($this->messages[self::ERROR_DUPLICATE_PHONE]),
$phone['value']
);
$this->skipRow($rowNumber, self::ERROR_DUPLICATE_PHONE, 'phone', $message);
}
}
}
return ! $this->errorHelper->isRowInvalid($rowNumber);
}
/**
* Start the import process.
*/
public function importBatch(ImportBatchContract $batch): bool
{
Event::dispatch('data_transfer.imports.batch.import.before', $batch);
if ($batch->import->action == Import::ACTION_DELETE) {
$this->deletePersons($batch);
} else {
$this->savePersonData($batch);
}
/**
* Update import batch summary.
*/
$batch = $this->importBatchRepository->update([
'state' => Import::STATE_PROCESSED,
'summary' => [
'created' => $this->getCreatedItemsCount(),
'updated' => $this->getUpdatedItemsCount(),
'deleted' => $this->getDeletedItemsCount(),
],
], $batch->id);
Event::dispatch('data_transfer.imports.batch.import.after', $batch);
return true;
}
/**
* Delete persons from current batch.
*/
protected function deletePersons(ImportBatchContract $batch): bool
{
/**
* Load person storage with batch emails.
*/
$emails = collect(Arr::pluck($batch->data, 'emails'))
->map(function ($emails) {
$emails = json_decode($emails, true);
foreach ($emails as $email) {
return $email['value'];
}
});
$this->personStorage->load($emails->toArray());
$idsToDelete = [];
foreach ($batch->data as $rowData) {
$rowData = $this->parsedRowData($rowData);
foreach ($rowData['emails'] as $email) {
if (! $this->isEmailExist($email['value'])) {
continue;
}
$idsToDelete[] = $this->personStorage->get($email['value']);
}
}
$idsToDelete = array_unique($idsToDelete);
$this->deletedItemsCount = count($idsToDelete);
$this->personRepository->deleteWhere([['id', 'IN', $idsToDelete]]);
return true;
}
/**
* Save person from current batch.
*/
protected function savePersonData(ImportBatchContract $batch): bool
{
/**
* Load person storage with batch email.
*/
$emails = collect(Arr::pluck($batch->data, 'emails'))
->map(function ($emails) {
$emails = json_decode($emails, true);
foreach ($emails as $email) {
return $email['value'];
}
});
$this->personStorage->load($emails->toArray());
$persons = [];
$attributeValues = [];
/**
* Prepare persons for import.
*/
foreach ($batch->data as $rowData) {
$this->preparePersons($rowData, $persons);
$this->prepareAttributeValues($rowData, $attributeValues);
}
$this->savePersons($persons);
$this->saveAttributeValues($attributeValues);
return true;
}
/**
* Prepare persons from current batch.
*/
public function preparePersons(array $rowData, array &$persons): void
{
$emails = $this->prepareEmail($rowData['emails']);
foreach ($emails as $email) {
$contactNumber = json_decode($rowData['contact_numbers'], true);
$rowData['unique_id'] = "{$rowData['user_id']}|{$rowData['organization_id']}|{$email}|{$contactNumber[0]['value']}";
if ($this->isEmailExist($email)) {
$persons['update'][$email] = $rowData;
} else {
$persons['insert'][$email] = [
...$rowData,
'created_at' => $rowData['created_at'] ?? now(),
'updated_at' => $rowData['updated_at'] ?? now(),
];
}
}
}
/**
* Save persons from current batch.
*/
public function savePersons(array $persons): void
{
if (! empty($persons['update'])) {
$this->updatedItemsCount += count($persons['update']);
$this->personRepository->upsert(
$persons['update'],
$this->masterAttributeCode,
);
}
if (! empty($persons['insert'])) {
$this->createdItemsCount += count($persons['insert']);
$this->personRepository->insert($persons['insert']);
/**
* Update the sku storage with newly created products
*/
$emails = array_keys($persons['insert']);
$newPersons = $this->personRepository->where(function ($query) use ($emails) {
foreach ($emails as $email) {
$query->orWhereJsonContains('emails', [['value' => $email]]);
}
})->get();
foreach ($newPersons as $person) {
$this->personStorage->set($person->emails[0]['value'], $person->id);
}
}
}
/**
* Save attribute values for the person.
*/
public function saveAttributeValues(array $attributeValues): void
{
$personAttributeValues = [];
foreach ($attributeValues as $email => $attributeValue) {
foreach ($attributeValue as $attribute) {
$attribute['entity_id'] = (int) $this->personStorage->get($email);
$attribute['unique_id'] = implode('|', array_filter([
$attribute['entity_id'],
$attribute['attribute_id'],
]));
$attribute['entity_type'] = 'persons';
$personAttributeValues[$attribute['unique_id']] = $attribute;
}
}
$this->attributeValueRepository->upsert($personAttributeValues, 'unique_id');
}
/**
* Check if email exists.
*/
public function isEmailExist(string $email): bool
{
return $this->personStorage->has($email);
}
/**
* Prepare attribute values for the person.
*/
public function prepareAttributeValues(array $rowData, array &$attributeValues): void
{
foreach ($rowData as $code => $value) {
if (is_null($value)) {
continue;
}
$where = ['code' => $code];
if ($code === 'name') {
$where['entity_type'] = 'persons';
}
$attribute = $this->attributeRepository->findOneWhere($where);
if (! $attribute) {
continue;
}
$typeFields = $this->personRepository->getModel()::$attributeTypeFields;
$attributeTypeValues = array_fill_keys(array_values($typeFields), null);
$emails = $this->prepareEmail($rowData['emails']);
foreach ($emails as $email) {
$attributeValues[$email][] = array_merge($attributeTypeValues, [
'attribute_id' => $attribute->id,
$typeFields[$attribute->type] => $value,
]);
}
}
}
/**
* Get parsed email and phone.
*/
private function parsedRowData(array $rowData): array
{
$rowData['emails'] = json_decode($rowData['emails'], true);
$rowData['contact_numbers'] = json_decode($rowData['contact_numbers'], true);
return $rowData;
}
/**
* Prepare email from row data.
*/
private function prepareEmail(array|string $emails): Collection
{
static $cache = [];
return collect($emails)
->map(function ($emailString) use (&$cache) {
if (isset($cache[$emailString])) {
return $cache[$emailString];
}
$decoded = json_decode($emailString, true);
$emailValue = is_array($decoded)
&& isset($decoded[0]['value'])
? $decoded[0]['value']
: null;
return $cache[$emailString] = $emailValue;
});
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Webkul\DataTransfer\Helpers\Importers\Persons;
use Webkul\Contact\Repositories\PersonRepository;
class Storage
{
/**
* Items contains email as key and product information as value.
*/
protected array $items = [];
/**
* Columns which will be selected from database.
*/
protected array $selectColumns = [
'id',
'emails',
];
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(protected PersonRepository $personRepository) {}
/**
* Initialize storage.
*/
public function init(): void
{
$this->items = [];
$this->load();
}
/**
* Load the Emails.
*/
public function load(array $emails = []): void
{
if (empty($emails)) {
$persons = $this->personRepository->all($this->selectColumns);
} else {
$persons = $this->personRepository->scopeQuery(function ($query) use ($emails) {
return $query->where(function ($subQuery) use ($emails) {
foreach ($emails as $email) {
$subQuery->orWhereJsonContains('emails', ['value' => $email]);
}
});
})->all($this->selectColumns);
}
$persons->each(function ($person) {
collect($person->emails)
->each(fn ($email) => $this->set($email['value'], $person->id));
});
}
/**
* Get email information.
*/
public function set(string $email, int $id): self
{
$this->items[$email] = $id;
return $this;
}
/**
* Check if email exists.
*/
public function has(string $email): bool
{
return isset($this->items[$email]);
}
/**
* Get email information.
*/
public function get(string $email): ?int
{
if (! $this->has($email)) {
return null;
}
return $this->items[$email];
}
/**
* Is storage is empty.
*/
public function isEmpty(): int
{
return empty($this->items);
}
}

View File

@@ -0,0 +1,369 @@
<?php
namespace Webkul\DataTransfer\Helpers\Importers\Products;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Validator;
use Webkul\Attribute\Repositories\AttributeOptionRepository;
use Webkul\Attribute\Repositories\AttributeRepository;
use Webkul\Attribute\Repositories\AttributeValueRepository;
use Webkul\DataTransfer\Contracts\ImportBatch as ImportBatchContract;
use Webkul\DataTransfer\Helpers\Import;
use Webkul\DataTransfer\Helpers\Importers\AbstractImporter;
use Webkul\DataTransfer\Repositories\ImportBatchRepository;
use Webkul\Product\Repositories\ProductInventoryRepository;
use Webkul\Product\Repositories\ProductRepository;
class Importer extends AbstractImporter
{
/**
* Error code for non existing SKU.
*/
const ERROR_SKU_NOT_FOUND_FOR_DELETE = 'sku_not_found_to_delete';
/**
* Error message templates.
*/
protected array $messages = [
self::ERROR_SKU_NOT_FOUND_FOR_DELETE => 'data_transfer::app.importers.products.validation.errors.sku-not-found',
];
/**
* Permanent entity columns.
*/
protected array $permanentAttributes = ['sku'];
/**
* Permanent entity column.
*/
protected string $masterAttributeCode = 'sku';
/**
* Cached attributes.
*/
protected mixed $attributes = [];
/**
* Valid csv columns.
*/
protected array $validColumnNames = [
'sku',
'name',
'description',
'quantity',
'price',
];
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(
protected ImportBatchRepository $importBatchRepository,
protected AttributeRepository $attributeRepository,
protected AttributeOptionRepository $attributeOptionRepository,
protected ProductRepository $productRepository,
protected ProductInventoryRepository $productInventoryRepository,
protected AttributeValueRepository $attributeValueRepository,
protected SKUStorage $skuStorage
) {
parent::__construct(
$importBatchRepository,
$attributeRepository,
$attributeValueRepository
);
$this->initAttributes();
}
/**
* Load all attributes and families to use later.
*/
protected function initAttributes(): void
{
$this->attributes = $this->attributeRepository->all();
foreach ($this->attributes as $attribute) {
$this->validColumnNames[] = $attribute->code;
}
}
/**
* Initialize Product error templates.
*/
protected function initErrorMessages(): void
{
foreach ($this->messages as $errorCode => $message) {
$this->errorHelper->addErrorMessage($errorCode, trans($message));
}
parent::initErrorMessages();
}
/**
* Save validated batches.
*/
protected function saveValidatedBatches(): self
{
$source = $this->getSource();
$source->rewind();
$this->skuStorage->init();
while ($source->valid()) {
try {
$rowData = $source->current();
} catch (\InvalidArgumentException $e) {
$source->next();
continue;
}
$this->validateRow($rowData, $source->getCurrentRowNumber());
$source->next();
}
parent::saveValidatedBatches();
return $this;
}
/**
* Validates row.
*/
public function validateRow(array $rowData, int $rowNumber): bool
{
/**
* If row is already validated than no need for further validation.
*/
if (isset($this->validatedRows[$rowNumber])) {
return ! $this->errorHelper->isRowInvalid($rowNumber);
}
$this->validatedRows[$rowNumber] = true;
/**
* If import action is delete than no need for further validation.
*/
if ($this->import->action == Import::ACTION_DELETE) {
if (! $this->isSKUExist($rowData['sku'])) {
$this->skipRow($rowNumber, self::ERROR_SKU_NOT_FOUND_FOR_DELETE, 'sku');
return false;
}
return true;
}
/**
* Validate product attributes
*/
$validator = Validator::make($rowData, $this->getValidationRules('products', $rowData));
if ($validator->fails()) {
foreach ($validator->errors()->getMessages() as $attributeCode => $message) {
$failedAttributes = $validator->failed();
$errorCode = array_key_first($failedAttributes[$attributeCode] ?? []);
$this->skipRow($rowNumber, $errorCode, $attributeCode, current($message));
}
}
return ! $this->errorHelper->isRowInvalid($rowNumber);
}
/**
* Start the import process.
*/
public function importBatch(ImportBatchContract $batch): bool
{
Event::dispatch('data_transfer.imports.batch.import.before', $batch);
if ($batch->import->action == Import::ACTION_DELETE) {
$this->deleteProducts($batch);
} else {
$this->saveProductsData($batch);
}
/**
* Update import batch summary.
*/
$batch = $this->importBatchRepository->update([
'state' => Import::STATE_PROCESSED,
'summary' => [
'created' => $this->getCreatedItemsCount(),
'updated' => $this->getUpdatedItemsCount(),
'deleted' => $this->getDeletedItemsCount(),
],
], $batch->id);
Event::dispatch('data_transfer.imports.batch.import.after', $batch);
return true;
}
/**
* Delete products from current batch.
*/
protected function deleteProducts(ImportBatchContract $batch): bool
{
/**
* Load SKU storage with batch skus.
*/
$this->skuStorage->load(Arr::pluck($batch->data, 'sku'));
$idsToDelete = [];
foreach ($batch->data as $rowData) {
if (! $this->isSKUExist($rowData['sku'])) {
continue;
}
$product = $this->skuStorage->get($rowData['sku']);
$idsToDelete[] = $product['id'];
}
$idsToDelete = array_unique($idsToDelete);
$this->deletedItemsCount = count($idsToDelete);
$this->productRepository->deleteWhere([['id', 'IN', $idsToDelete]]);
return true;
}
/**
* Save products from current batch.
*/
protected function saveProductsData(ImportBatchContract $batch): bool
{
/**
* Load SKU storage with batch skus.
*/
$this->skuStorage->load(Arr::pluck($batch->data, 'sku'));
$products = [];
/**
* Prepare products for import.
*/
foreach ($batch->data as $rowData) {
$this->prepareProducts($rowData, $products);
}
$this->saveProducts($products);
return true;
}
/**
* Prepare products from current batch.
*/
public function prepareProducts(array $rowData, array &$products): void
{
if ($this->isSKUExist($rowData['sku'])) {
$products['update'][$rowData['sku']] = $rowData;
} else {
$products['insert'][$rowData['sku']] = [
...$rowData,
'created_at' => $rowData['created_at'] ?? now(),
'updated_at' => $rowData['updated_at'] ?? now(),
];
}
}
/**
* Save products from current batch.
*/
public function saveProducts(array $products): void
{
if (! empty($products['update'])) {
$this->updatedItemsCount += count($products['update']);
$this->productRepository->upsert(
$products['update'],
$this->masterAttributeCode
);
}
if (! empty($products['insert'])) {
$this->createdItemsCount += count($products['insert']);
$this->productRepository->insert($products['insert']);
}
}
/**
* Save channels from current batch.
*/
public function saveChannels(array $channels): void
{
$productChannels = [];
foreach ($channels as $sku => $channelIds) {
$product = $this->skuStorage->get($sku);
foreach (array_unique($channelIds) as $channelId) {
$productChannels[] = [
'product_id' => $product['id'],
'channel_id' => $channelId,
];
}
}
DB::table('product_channels')->upsert(
$productChannels,
[
'product_id',
'channel_id',
],
);
}
/**
* Save links.
*/
public function loadUnloadedSKUs(array $skus): void
{
$notLoadedSkus = [];
foreach ($skus as $sku) {
if ($this->skuStorage->has($sku)) {
continue;
}
$notLoadedSkus[] = $sku;
}
/**
* Load not loaded SKUs to the sku storage.
*/
if (! empty($notLoadedSkus)) {
$this->skuStorage->load($notLoadedSkus);
}
}
/**
* Check if SKU exists.
*/
public function isSKUExist(string $sku): bool
{
return $this->skuStorage->has($sku);
}
/**
* Prepare row data to save into the database.
*/
protected function prepareRowForDb(array $rowData): array
{
return parent::prepareRowForDb($rowData);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Webkul\DataTransfer\Helpers\Importers\Products;
use Webkul\Product\Repositories\ProductRepository;
class SKUStorage
{
/**
* Delimiter for SKU information.
*/
private const DELIMITER = '|';
/**
* Items contains SKU as key and product information as value.
*/
protected array $items = [];
/**
* Columns which will be selected from database.
*/
protected array $selectColumns = [
'id',
'sku',
];
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(protected ProductRepository $productRepository) {}
/**
* Initialize storage.
*/
public function init(): void
{
$this->items = [];
$this->load();
}
/**
* Load the SKU.
*/
public function load(array $skus = []): void
{
if (empty($skus)) {
$products = $this->productRepository->all($this->selectColumns);
} else {
$products = $this->productRepository->findWhereIn('sku', $skus, $this->selectColumns);
}
foreach ($products as $product) {
$this->set($product->sku, [
'id' => $product->id,
'sku' => $product->sku,
]);
}
}
/**
* Get SKU information.
*/
public function set(string $sku, array $data): self
{
$this->items[$sku] = implode(self::DELIMITER, [
$data['id'],
$data['sku'],
]);
return $this;
}
/**
* Check if SKU exists.
*/
public function has(string $sku): bool
{
return isset($this->items[$sku]);
}
/**
* Get SKU information.
*/
public function get(string $sku): ?array
{
if (! $this->has($sku)) {
return null;
}
$data = explode(self::DELIMITER, $this->items[$sku]);
return [
'id' => $data[0],
];
}
/**
* Is storage is empty.
*/
public function isEmpty(): int
{
return empty($this->items);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace Webkul\DataTransfer\Helpers\Sources;
use Webkul\DataTransfer\Helpers\Importers\AbstractImporter;
abstract class AbstractSource
{
/**
* Column names.
*/
protected array $columnNames = [];
/**
* Quantity of columns.
*/
protected int $totalColumns = 0;
/**
* Current row.
*/
protected array $currentRowData = [];
/**
* Current row number.
*/
protected int $currentRowNumber = -1;
/**
* Flag to indicate that wrong quote was found.
*/
protected bool $foundWrongQuoteFlag = false;
/**
* Read next line from source.
*/
abstract protected function getNextRow(): array|bool;
/**
* Return the key of the current row.
*/
public function getCurrentRowNumber(): int
{
return $this->currentRowNumber;
}
/**
* Checks if current position is valid.
*/
public function valid(): bool
{
return $this->currentRowNumber !== -1;
}
/**
* Read next line from source.
*/
public function current(): array
{
$row = $this->currentRowData;
if (count($row) != $this->totalColumns) {
if ($this->foundWrongQuoteFlag) {
throw new \InvalidArgumentException(AbstractImporter::ERROR_CODE_WRONG_QUOTES);
} else {
throw new \InvalidArgumentException(AbstractImporter::ERROR_CODE_COLUMNS_NUMBER);
}
}
return array_combine($this->columnNames, $row);
}
/**
* Read next line from source.
*/
public function next(): void
{
$this->currentRowNumber++;
$row = $this->getNextRow();
if ($row === false || $row === []) {
$this->currentRowData = [];
$this->currentRowNumber = -1;
} else {
$this->currentRowData = $row;
}
}
/**
* Rewind the iterator to the first row.
*/
public function rewind(): void
{
$this->currentRowNumber = 0;
$this->currentRowData = [];
$this->getNextRow();
$this->next();
}
/**
* Column names getter.
*/
public function getColumnNames(): array
{
return $this->columnNames;
}
/**
* Column names getter.
*/
public function getTotalColumns(): int
{
return count($this->columnNames);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Webkul\DataTransfer\Helpers\Sources;
use Illuminate\Support\Facades\Storage;
class CSV extends AbstractSource
{
/**
* CSV reader.
*/
protected mixed $reader;
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(
string $filePath,
protected string $delimiter = ','
) {
try {
$this->reader = fopen(Storage::disk('public')->path($filePath), 'r');
$this->columnNames = fgetcsv($this->reader, 4096, $delimiter);
$this->totalColumns = count($this->columnNames);
} catch (\Exception $e) {
throw new \LogicException("Unable to open file: '{$filePath}'");
}
}
/**
* Close file handle.
*
* @return void
*/
public function __destruct()
{
if (! is_object($this->reader)) {
return;
}
$this->reader->close();
}
/**
* Read next line from csv.
*/
protected function getNextRow(): array
{
$parsed = fgetcsv($this->reader, 4096, $this->delimiter);
if (is_array($parsed) && count($parsed) != $this->totalColumns) {
foreach ($parsed as $element) {
if ($element && strpos($element, "'") !== false) {
$this->foundWrongQuoteFlag = true;
break;
}
}
} else {
$this->foundWrongQuoteFlag = false;
}
return is_array($parsed) ? $parsed : [];
}
/**
* Rewind the iterator to the first row.
*/
public function rewind(): void
{
rewind($this->reader);
parent::rewind();
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Webkul\DataTransfer\Helpers\Sources;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
class Excel extends AbstractSource
{
/**
* CSV reader.
*/
protected mixed $reader;
/**
* Current row number.
*/
protected int $currentRowNumber = 1;
/**
* Create a new helper instance.
*
* @return void
*/
public function __construct(string $filePath)
{
try {
$factory = IOFactory::load(Storage::disk('public')->path($filePath));
$this->reader = $factory->getActiveSheet();
$highestColumn = $this->reader->getHighestColumn();
$this->totalColumns = Coordinate::columnIndexFromString($highestColumn);
$this->columnNames = $this->getNextRow();
} catch (\Exception $e) {
throw new \LogicException("Unable to open file: '{$filePath}'");
}
}
/**
* Read next line from csv.
*/
protected function getNextRow(): array|bool
{
for ($column = 1; $column <= $this->totalColumns; $column++) {
$rowData[] = $this->reader->getCellByColumnAndRow($column, $this->currentRowNumber)->getValue();
}
$filteredRowData = array_filter($rowData);
if (empty($filteredRowData)) {
return false;
}
return $rowData;
}
/**
* Rewind the iterator to the first row.
*/
public function rewind(): void
{
$this->currentRowNumber = 1;
$this->next();
}
}