|
|
@@ -0,0 +1,503 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace Longyi\Core\Services;
|
|
|
+
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+use Illuminate\Support\Facades\Schema;
|
|
|
+use Longyi\Core\Support\ProductBasicInfoColumns;
|
|
|
+use Webkul\Attribute\Repositories\AttributeRepository;
|
|
|
+use Webkul\Product\Helpers\Indexers\ElasticSearch;
|
|
|
+use Webkul\Product\Helpers\Indexers\Flat;
|
|
|
+use Webkul\Product\Helpers\Indexers\Price;
|
|
|
+use Webkul\Product\Models\Product;
|
|
|
+use Webkul\Product\Repositories\ProductAttributeValueRepository;
|
|
|
+use Webkul\Product\Repositories\ProductRepository;
|
|
|
+
|
|
|
+class ProductBasicInfoSyncService
|
|
|
+{
|
|
|
+ /**
|
|
|
+ * @var array<string, int>
|
|
|
+ */
|
|
|
+ protected array $productsBySku = [];
|
|
|
+
|
|
|
+ public function __construct(
|
|
|
+ protected ProductRepository $productRepository,
|
|
|
+ protected ProductAttributeValueRepository $productAttributeValueRepository,
|
|
|
+ protected AttributeRepository $attributeRepository,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param list<list<mixed>> $table First row is headings.
|
|
|
+ */
|
|
|
+ public function syncFromTable(array $table, bool $reindex = true): ProductBasicInfoSyncResult
|
|
|
+ {
|
|
|
+ return $this->sync($this->mapTableToRows($table), $reindex);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param list<list<mixed>> $table
|
|
|
+ * @return list<array<string, mixed>>
|
|
|
+ */
|
|
|
+ public function mapTableToRows(array $table): array
|
|
|
+ {
|
|
|
+ if ($table === []) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.empty-file'));
|
|
|
+ }
|
|
|
+
|
|
|
+ $headings = [];
|
|
|
+
|
|
|
+ foreach ((array) array_shift($table) as $heading) {
|
|
|
+ $headings[] = strtolower(trim((string) $heading));
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! in_array(ProductBasicInfoColumns::SKU, $headings, true)) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.sku-column-missing'));
|
|
|
+ }
|
|
|
+
|
|
|
+ $rows = [];
|
|
|
+
|
|
|
+ foreach ($table as $line) {
|
|
|
+ $row = [];
|
|
|
+
|
|
|
+ foreach ($headings as $index => $heading) {
|
|
|
+ if ($heading === '') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $row[$heading] = $line[$index] ?? null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $rows[] = $row;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $rows;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param list<array<string, mixed>> $rows
|
|
|
+ */
|
|
|
+ public function sync(array $rows, bool $reindex = true): ProductBasicInfoSyncResult
|
|
|
+ {
|
|
|
+ $result = new ProductBasicInfoSyncResult;
|
|
|
+ $touchedProductIds = [];
|
|
|
+ $seenSkus = [];
|
|
|
+
|
|
|
+ foreach ($rows as $index => $row) {
|
|
|
+ $excelRow = $index + 2;
|
|
|
+ $sku = $this->cellString($row[ProductBasicInfoColumns::SKU] ?? '');
|
|
|
+
|
|
|
+ if ($sku === '' && $this->rowIsEmpty($row)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($sku === '') {
|
|
|
+ $result->addError($excelRow, '', trans('longyi::app.product-sync.errors.sku-required'));
|
|
|
+
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isset($seenSkus[$sku])) {
|
|
|
+ $result->addError(
|
|
|
+ $excelRow,
|
|
|
+ $sku,
|
|
|
+ trans('longyi::app.product-sync.errors.duplicate-sku', ['row' => $seenSkus[$sku]])
|
|
|
+ );
|
|
|
+
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $seenSkus[$sku] = $excelRow;
|
|
|
+
|
|
|
+ try {
|
|
|
+ $productId = $this->syncRow($row, $sku);
|
|
|
+ $result->updated++;
|
|
|
+ $touchedProductIds[] = $productId;
|
|
|
+ } catch (ProductBasicInfoSyncException $e) {
|
|
|
+ $result->addError($excelRow, $sku, $e->getMessage());
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ $result->addError($excelRow, $sku, $e->getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($reindex) {
|
|
|
+ $this->reindexProducts($touchedProductIds);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param array<string, mixed> $row
|
|
|
+ */
|
|
|
+ protected function syncRow(array $row, string $sku): int
|
|
|
+ {
|
|
|
+ $product = $this->findProductBySku($sku);
|
|
|
+
|
|
|
+ if (! $product) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.sku-not-found'));
|
|
|
+ }
|
|
|
+
|
|
|
+ $locale = $this->cellString($row[ProductBasicInfoColumns::LOCALE] ?? '');
|
|
|
+
|
|
|
+ if ($locale === '') {
|
|
|
+ $locale = core()->getDefaultLocaleCodeFromDefaultChannel();
|
|
|
+ }
|
|
|
+
|
|
|
+ $channel = core()->getDefaultChannelCode();
|
|
|
+ $presentCodes = $this->presentAttributeCodes($row);
|
|
|
+ $payload = [
|
|
|
+ 'channel' => $channel,
|
|
|
+ 'locale' => $locale,
|
|
|
+ ];
|
|
|
+
|
|
|
+ foreach ($presentCodes as $code) {
|
|
|
+ $raw = $row[$code] ?? null;
|
|
|
+ $value = $this->normalizeAttributeValue($code, $raw);
|
|
|
+
|
|
|
+ if (
|
|
|
+ in_array($code, ProductBasicInfoColumns::REQUIRED_ATTRIBUTE_CODES, true)
|
|
|
+ && $this->isEmptyValue($value)
|
|
|
+ ) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.field-required', [
|
|
|
+ 'field' => $code,
|
|
|
+ ]));
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($code === 'status' && $this->isEmptyValue($raw)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($code === 'url_key' && ! $this->isUrlKeyUnique($product->id, (string) $value, $locale)) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.url-key-taken'));
|
|
|
+ }
|
|
|
+
|
|
|
+ $payload[$code] = $value;
|
|
|
+ }
|
|
|
+
|
|
|
+ $attributeCodes = array_values(array_intersect($presentCodes, array_keys($payload)));
|
|
|
+
|
|
|
+ $categoryNames = null;
|
|
|
+
|
|
|
+ if (array_key_exists(ProductBasicInfoColumns::CATEGORIES, $row)) {
|
|
|
+ $categoriesRaw = $this->cellString($row[ProductBasicInfoColumns::CATEGORIES] ?? '');
|
|
|
+
|
|
|
+ if ($categoriesRaw !== '') {
|
|
|
+ $categoryNames = $this->splitCategories($categoriesRaw);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $qty = null;
|
|
|
+
|
|
|
+ if (array_key_exists(ProductBasicInfoColumns::QTY, $row)) {
|
|
|
+ $qtyRaw = $this->cellString($row[ProductBasicInfoColumns::QTY] ?? '');
|
|
|
+
|
|
|
+ if ($qtyRaw !== '') {
|
|
|
+ if (! is_numeric($qtyRaw)) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.invalid-qty'));
|
|
|
+ }
|
|
|
+
|
|
|
+ $qty = (int) $qtyRaw;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $categoryIds = $categoryNames !== null
|
|
|
+ ? $this->resolveCategoryIds($categoryNames, $locale)
|
|
|
+ : null;
|
|
|
+
|
|
|
+ DB::transaction(function () use ($product, $payload, $attributeCodes, $categoryIds, $qty) {
|
|
|
+ if ($attributeCodes !== []) {
|
|
|
+ $this->productRepository->update($payload, $product->id, $attributeCodes);
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($categoryIds !== null) {
|
|
|
+ if (Schema::hasColumn('product_categories', 'position')) {
|
|
|
+ $sync = [];
|
|
|
+
|
|
|
+ foreach ($categoryIds as $position => $categoryId) {
|
|
|
+ $sync[$categoryId] = ['position' => $position];
|
|
|
+ }
|
|
|
+
|
|
|
+ $product->categories()->sync($sync);
|
|
|
+ } else {
|
|
|
+ $product->categories()->sync($categoryIds);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($qty !== null) {
|
|
|
+ $this->saveInventory($product, $qty);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return (int) $product->id;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param array<string, mixed> $row
|
|
|
+ * @return list<string>
|
|
|
+ */
|
|
|
+ protected function presentAttributeCodes(array $row): array
|
|
|
+ {
|
|
|
+ $codes = [];
|
|
|
+
|
|
|
+ foreach (ProductBasicInfoColumns::ATTRIBUTE_CODES as $code) {
|
|
|
+ if (array_key_exists($code, $row)) {
|
|
|
+ $codes[] = $code;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $codes;
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function normalizeAttributeValue(string $code, mixed $raw): mixed
|
|
|
+ {
|
|
|
+ if ($code === 'special_price' && $this->isEmptyValue($raw)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($code === 'status') {
|
|
|
+ if ($this->isEmptyValue($raw)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $normalized = strtolower($this->cellString($raw));
|
|
|
+
|
|
|
+ if (in_array($normalized, ['1', 'true', 'yes', 'enabled'], true)) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (in_array($normalized, ['0', 'false', 'no', 'disabled'], true)) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.invalid-status'));
|
|
|
+ }
|
|
|
+
|
|
|
+ if (in_array($code, ['price', 'special_price', 'weight'], true)) {
|
|
|
+ if ($this->isEmptyValue($raw)) {
|
|
|
+ return $code === 'special_price' ? null : '';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! is_numeric($raw) && ! is_numeric($this->cellString($raw))) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.invalid-number', [
|
|
|
+ 'field' => $code,
|
|
|
+ ]));
|
|
|
+ }
|
|
|
+
|
|
|
+ return (float) $raw;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->cellString($raw);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function findProductBySku(string $sku): ?Product
|
|
|
+ {
|
|
|
+ if ($this->productsBySku === []) {
|
|
|
+ $this->productsBySku = Product::query()
|
|
|
+ ->pluck('id', 'sku')
|
|
|
+ ->mapWithKeys(fn ($id, $skuKey) => [(string) $skuKey => (int) $id])
|
|
|
+ ->all();
|
|
|
+ }
|
|
|
+
|
|
|
+ $id = $this->productsBySku[$sku] ?? null;
|
|
|
+
|
|
|
+ if (! $id) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return Product::query()->find($id);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function isUrlKeyUnique(int $productId, string $urlKey, string $locale): bool
|
|
|
+ {
|
|
|
+ $attribute = $this->attributeRepository->findOneByField('code', 'url_key');
|
|
|
+
|
|
|
+ if (! $attribute) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return ! DB::table('product_attribute_values')
|
|
|
+ ->where('attribute_id', $attribute->id)
|
|
|
+ ->where('text_value', $urlKey)
|
|
|
+ ->where('product_id', '!=', $productId)
|
|
|
+ ->when(
|
|
|
+ Schema::hasColumn('product_attribute_values', 'locale'),
|
|
|
+ fn ($query) => $query->where(function ($inner) use ($locale) {
|
|
|
+ $inner->where('locale', $locale)->orWhereNull('locale');
|
|
|
+ })
|
|
|
+ )
|
|
|
+ ->exists();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param list<string> $names
|
|
|
+ * @return list<int>
|
|
|
+ */
|
|
|
+ protected function resolveCategoryIds(array $names, string $locale): array
|
|
|
+ {
|
|
|
+ if ($names === [] || ! Schema::hasTable('category_translations')) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.unknown-category', [
|
|
|
+ 'name' => implode('|', $names),
|
|
|
+ ]));
|
|
|
+ }
|
|
|
+
|
|
|
+ $query = DB::table('category_translations')->whereIn('name', $names);
|
|
|
+
|
|
|
+ if (Schema::hasColumn('category_translations', 'locale')) {
|
|
|
+ $query->where('locale', $locale);
|
|
|
+ }
|
|
|
+
|
|
|
+ $found = $query->get(['name', 'category_id']);
|
|
|
+ $idsByName = [];
|
|
|
+
|
|
|
+ foreach ($found as $row) {
|
|
|
+ $idsByName[(string) $row->name] = (int) $row->category_id;
|
|
|
+ }
|
|
|
+
|
|
|
+ $missing = array_values(array_diff($names, array_keys($idsByName)));
|
|
|
+
|
|
|
+ if ($missing !== []) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.unknown-category', [
|
|
|
+ 'name' => implode('|', $missing),
|
|
|
+ ]));
|
|
|
+ }
|
|
|
+
|
|
|
+ return array_values(array_unique(array_map(
|
|
|
+ fn (string $name) => $idsByName[$name],
|
|
|
+ $names
|
|
|
+ )));
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function saveInventory(Product $product, int $qty): void
|
|
|
+ {
|
|
|
+ $existing = DB::table('product_inventories')
|
|
|
+ ->where('product_id', $product->id)
|
|
|
+ ->orderBy('id')
|
|
|
+ ->first();
|
|
|
+
|
|
|
+ $sourceId = $existing?->inventory_source_id
|
|
|
+ ?? DB::table('inventory_sources')->value('id');
|
|
|
+
|
|
|
+ if (! $sourceId) {
|
|
|
+ throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.no-inventory-source'));
|
|
|
+ }
|
|
|
+
|
|
|
+ DB::table('product_inventories')->updateOrInsert(
|
|
|
+ [
|
|
|
+ 'product_id' => $product->id,
|
|
|
+ 'inventory_source_id' => $sourceId,
|
|
|
+ 'vendor_id' => $existing?->vendor_id ?? 0,
|
|
|
+ ],
|
|
|
+ ['qty' => $qty]
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param list<int> $productIds
|
|
|
+ */
|
|
|
+ public function reindexProducts(array $productIds): void
|
|
|
+ {
|
|
|
+ $ids = array_values(array_unique(array_filter($productIds)));
|
|
|
+
|
|
|
+ if ($ids === []) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $priceIndexer = app(Price::class);
|
|
|
+ $flatIndexer = app(Flat::class);
|
|
|
+ $elasticIndexer = $this->elasticEnabled() ? app(ElasticSearch::class) : null;
|
|
|
+
|
|
|
+ $relations = [
|
|
|
+ 'attribute_family',
|
|
|
+ 'attribute_values',
|
|
|
+ 'channels',
|
|
|
+ 'price_indices',
|
|
|
+ 'customer_group_prices',
|
|
|
+ 'catalog_rule_prices',
|
|
|
+ 'flexibleVariants',
|
|
|
+ 'flexibleVariants.price_indices',
|
|
|
+ 'flexibleVariants.customer_group_prices',
|
|
|
+ ];
|
|
|
+
|
|
|
+ foreach (array_chunk($ids, 20) as $chunk) {
|
|
|
+ $products = Product::with($relations)->whereIn('id', $chunk)->get();
|
|
|
+
|
|
|
+ foreach ($products as $product) {
|
|
|
+ try {
|
|
|
+ $priceIndexer->reindexRow($product);
|
|
|
+ $flatIndexer->reindexRow($product);
|
|
|
+ $elasticIndexer?->reindexRow($product);
|
|
|
+ } catch (\Throwable) {
|
|
|
+ // Keep the spreadsheet update even if a single index rebuild fails.
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function elasticEnabled(): bool
|
|
|
+ {
|
|
|
+ return core()->getConfigData('catalog.products.search.engine') === 'elastic';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @return list<string>
|
|
|
+ */
|
|
|
+ protected function splitCategories(string $raw): array
|
|
|
+ {
|
|
|
+ return array_values(array_unique(array_filter(array_map(
|
|
|
+ 'trim',
|
|
|
+ preg_split('/[|,]/', $raw) ?: []
|
|
|
+ ), fn (string $name) => $name !== '')));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param array<string, mixed> $row
|
|
|
+ */
|
|
|
+ protected function rowIsEmpty(array $row): bool
|
|
|
+ {
|
|
|
+ foreach ($row as $value) {
|
|
|
+ if (! $this->isEmptyValue($value)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function isEmptyValue(mixed $value): bool
|
|
|
+ {
|
|
|
+ if ($value === null) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_string($value)) {
|
|
|
+ return trim($value) === '';
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function cellString(mixed $value): string
|
|
|
+ {
|
|
|
+ if ($value === null) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_bool($value)) {
|
|
|
+ return $value ? '1' : '0';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_int($value)) {
|
|
|
+ return (string) $value;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_float($value)) {
|
|
|
+ if (floor($value) == $value) {
|
|
|
+ return (string) (int) $value;
|
|
|
+ }
|
|
|
+
|
|
|
+ return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return trim((string) $value);
|
|
|
+ }
|
|
|
+}
|