15 Commits

Author SHA1 Message Date
arvanitakis 1d25e0d746 Hotfix: Correctly parsing shopify's url handle 2026-07-10 13:42:10 +03:00
arvanitakis 96ad89b6b8 Bump composer version 2026-07-10 12:51:29 +03:00
arvanitakis c07b36e1f9 Hotfix: Adding countries to lunar install 2026-07-10 12:38:48 +03:00
arvanitakis 2767a4bb69 Hotfix: Overriding Lunar Install command for non-interactive inputs 2026-07-10 12:31:59 +03:00
arvanitakis 1d2a90059d Hotfix: Correcting core version to 0.1.0 2026-07-09 00:56:04 +03:00
arvanitakis 364368ba3d Merge pull request 'Feature: Add Shopify product importer and table-rate shipping; bump to 0.2.0' (#1) from 1811058982446433859-creating-product-importer into master
Reviewed-on: #1
2026-07-08 21:40:15 +00:00
arvanitakis 51c43c11b5 Bump version to 0.2.0 2026-07-09 00:39:30 +03:00
arvanitakis 90ac32094c Feature: Adding Docs, CONTRIBUTE.md 2026-07-09 00:37:32 +03:00
arvanitakis df03866285 Feature: Creating product import resolvers, wiring import job 2026-07-09 00:37:14 +03:00
arvanitakis 825ce885c0 Feature: Adding Migration To Track import mappings 2026-07-08 23:41:23 +03:00
arvanitakis cd9632a6bf Feature: Creating Skeleton for import command, interfaces, shopify importer 2026-07-06 03:42:24 +03:00
arvanitakis 677b240a8d Feature: Adding shipping options to Core 2026-07-06 03:10:34 +03:00
arvanitakis ab213f69ec Feat: Adding Migration for otp to lunar_customers 2026-07-03 17:16:30 +03:00
arvanitakis ebe905acfa Bump composer.json version to 0.0.2
Composer's VCS driver cross-checks the tag against the version field
in composer.json and skips tags that disagree, so this must match
the v0.0.2 tag for it to resolve.
2026-07-03 15:09:28 +03:00
arvanitakis d35dff3731 Fix: remove hardcoded Lucent nav item from CorePlugin
CorePlugin registered a "Lucent" navigation item pointing at /lucent
unconditionally, coupling the core package to a specific CMS that
consuming apps may not install.
2026-07-03 15:08:05 +03:00
30 changed files with 1465 additions and 11 deletions
+20
View File
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.1.0] - 2026-07-09
### Added
- **Shipping**: registered Lunar's `lunarphp/table-rate-shipping` plugin (`ShippingPlugin`) directly on `CorePlugin`, so table-rate shipping is available to every consumer app without per-app wiring.
- **Product migration/import framework** (`Modules\Core\MigrateImport`): a source-agnostic pipeline for importing a vendor's product catalog into Lunar.
- `boboko:migrate:import` Artisan command — interactively prompts for source, type (export/API), and credentials or file path, then dispatches the import as a queued job (`RunMigrateImportJob`) on the default queue. The file-path prompt resolves relative to `storage/app/private/imports/`, so answering e.g. `shopify` picks up the first CSV found in `imports/shopify/` automatically.
- `ImportSpec`, `Importer` interface, and `ImporterFactory` (source+type → importer class) as the extension points for future sources (WooCommerce, etc.) and mechanisms (API vs. file export).
- `import_mappings` table + `ImportMapping` model: a polymorphic (source, source_type, external_id) → model mapping used by every resolver to make imports idempotent and safely re-runnable.
- `DefaultLocale` helper wrapping Lunar's `Language::getDefault()->code`, used anywhere a translatable field needs a locale key, instead of assuming `app()->getLocale()` matches Lunar's configured default.
- **Shopify CSV export importer** (`Shopify\ShopifyExportImporter`), the first working source/type combination, verified end-to-end against a real 183-product/693-variant/332-image Shopify export (row counts in the CSV match 1:1 with imported Products/Variants/Media):
- `ShopifyCsvReader` + `ProductGroup` group Shopify's flat, repeated-handle CSV rows into one row-group per product (product row, variant rows, image rows).
- Ten resolvers under `Shopify\Resolvers`, each responsible for idempotently resolving-or-creating one Lunar entity: `TaxClassResolver`, `ProductTypeResolver` (auto-attaches system attributes to new types), `BrandResolver`, `TagResolver`, `CollectionResolver` (multi-level, multi-collection support via `>`-delimited breadcrumbs), `ProductOptionResolver` (dedupes options/values by slugified name so case variants like "Size"/"size" resolve to one row), `AssetResolver` (Spatie MediaLibrary via `Product::addMedia()`, matches local export images by UUID first, filename fallback), `PriceResolver` (minor-unit conversion per currency), `ImportAttributeResolver` and `ProductAttributeResolver` (custom `cost_per_item`/`seo_title`/`seo_description` attributes, field-type-aware `attribute_data` writing).
- `docs/shopify-import.md` — full CSV-to-Lunar field mapping reference and import design notes.
- `docs/lunar.md` — new "Gotchas" section documenting non-obvious Lunar behavior hit while building the importer (table-prefix/nested-set race, required `ProductOption.handle`, per-group `Attribute.position`, etc.).
- `CONTRIBUTE.md` — local dev setup (path-repo + `bin/dc-core.sh`), and the manual DB-verification workflow used to build this feature.
### Fixed
- `ProductOptionResolver` created duplicate `ProductOption`/`ProductOptionValue` rows when the same option or value appeared with different casing across products (e.g. Shopify export rows using both "Size" and "size"), and could create a duplicate value within a single product's own variant rows due to relying on a stale lazy-loaded relation. Both now resolve by normalized (slugified) identity queried fresh from the database.
- `boboko:migrate:import` could dispatch an import job with a blank file path (silent no-op failure) if the file-path prompt was answered empty; it now re-prompts until a valid, existing file is given.
## [0.0.1] - 2026-07-03 ## [0.0.1] - 2026-07-03
First release. First release.
+67
View File
@@ -0,0 +1,67 @@
# Contributing to boboko-core
This is a Composer library, not a runnable app — you can't `php artisan serve` it directly. To develop and verify changes, you need a consumer app wired to a local checkout via a Composer path repository, plus a real database, since a large part of this package (Lunar models, migrations, Filament panel resources) can only be meaningfully verified against a live Lunar install.
## Local dev setup
This works against any consumer app that follows the same convention — `boboko-test`, `boboko-starter`, `boboko-3dealer`, etc. — checked out next to this repo:
```
RadicalElements/
├── boboko-core/ (this repo)
└── boboko-test/ (or boboko-starter, boboko-3dealer, ... — consumer app, Docker-based)
```
Each of these consumer apps ships a `bin/dc-core.sh` helper that wraps the Docker Compose overlay needed to bind-mount a local `boboko-core` checkout into the app container:
```bash
./bin/dc-core.sh exec app <command>
```
This is shorthand for `docker compose -f docker-compose.dev.yml -f docker-compose.core-dev.yml exec app <command>`. Use `./bin/dc-core.sh` for everything below instead of typing the full compose invocation.
1. **Path repository.** In the consumer app's `composer.json`, the `repositories` array needs a path entry pointing at `../boboko-core`. If it only exists in a disabled block (e.g. `_repositories`), move it into the live array.
2. **Relaxed version constraint.** The consumer app's `composer.json` should require `"boboko/core": "0.*"` (not a tight `^0.0.1` caret) — otherwise Composer rejects newer `0.0.x` versions resolved from the path repo.
3. **Bind mount.** The consumer app's `docker-compose.core-dev.yml` overlays `../boboko-core` into the container at `/var/www/boboko-core`, matching where the path repo resolves it relative to `/var/www/html`.
4. **Re-resolve after every change.** Composer's path repo does not hot-reload — after editing anything in `boboko-core` (including adding new files, which need autoload discovery), the container needs to re-run `composer update boboko/core`. In `boboko-test`, the entrypoint does this automatically on every dev boot (see `docker/entrypoint.sh`), so `./bin/dc-core.sh up` alone picks up local core changes. If a consumer app's entrypoint doesn't do this yet, run it manually:
```bash
./bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies
```
Skipping this step is the most common cause of "my change isn't showing up."
## Verifying changes against a real database
There is no automated test suite for this package — too much of Lunar's behavior (table prefixing, nested sets, translatable attributes, Filament panel filters) only breaks in combination, against real Postgres, in a way that's impractical to fake in isolation. Instead, verify changes directly against a consumer app's live database. The practical workflow used throughout this package's `MigrateImport` feature:
**One-off checks**, via `php artisan tinker --execute="..."` in the consumer app's container:
```bash
./bin/dc-core.sh exec app php artisan tinker --execute="
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
\$type = (new ProductTypeResolver())->resolve('Test Type');
echo \$type->name . PHP_EOL;
"
```
**Multi-step scripts** (creating related records, checking round-trips), via a raw PHP script bootstrapped like an Artisan command — this avoids tinker's persistent-session quirks and more closely matches how code actually runs in a queued job:
```bash
./bin/dc-core.sh exec app php -r "
require 'vendor/autoload.php';
\$app = require 'bootstrap/app.php';
\$kernel = \$app->make(Illuminate\Contracts\Console\Kernel::class);
\$kernel->bootstrap();
// ... your verification code ...
"
```
**Always clean up fixtures** created this way — either via `DB::statement('DELETE FROM ...')` in the same script (respecting foreign key order — Lunar has cascading relations like `lunar_customer_group_product` that block naive deletes), or leave them if they're harmless and the DB is a disposable dev/test instance.
Non-obvious Lunar bugs/traps hit while building against it are documented in [docs/lunar.md](docs/lunar.md#gotchas), not here — check there before debugging something that looks like a Lunar quirk.
## Code organization
Feature work lives under `src/<Feature>/`, with models in a `Models/` subdirectory (see `src/Auth/Models`, `src/Customer/Models`, `src/MigrateImport/Models`). Source-specific importer logic is grouped by source name (e.g. `src/MigrateImport/Shopify/`), with resolver classes (one responsibility each — resolve-or-create a single Lunar entity) grouped further under `Resolvers/`.
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.0.1", "version": "0.1.2",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -13,7 +13,8 @@
"lunarphp/lunar": "1.3.0", "lunarphp/lunar": "1.3.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"symfony/yaml": "^7.0" "symfony/yaml": "^7.0",
"lunarphp/table-rate-shipping": "^1.3"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_customers', function (Blueprint $table) {
$table->string('otp_code', 6)->nullable()->after('meta');
$table->timestamp('otp_expires_at')->nullable()->after('otp_code');
});
}
public function down(): void
{
Schema::table('lunar_customers', function (Blueprint $table) {
$table->dropColumn(['otp_code', 'otp_expires_at']);
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('import_mappings', function (Blueprint $table) {
$table->id();
$table->string('source');
$table->string('source_type');
$table->string('external_id');
$table->morphs('model');
$table->timestamps();
$table->unique(['source', 'source_type', 'external_id']);
});
}
public function down(): void
{
Schema::dropIfExists('import_mappings');
}
};
+15
View File
@@ -1191,3 +1191,18 @@ php artisan vendor:publish --tag=lunar.migrations # publish migrations
php artisan scout:import "Lunar\Models\Product" # index products php artisan scout:import "Lunar\Models\Product" # index products
php artisan scout:flush "Lunar\Models\Order" # flush order index php artisan scout:flush "Lunar\Models\Order" # flush order index
``` ```
---
## Gotchas
Real bugs/traps hit while building against Lunar in this package — not obvious from reading Lunar's source in isolation.
- **`Lunar\Base\BaseModel` prefixes table names at construction time.** Mass-assigning a `parent_id` on a model using `kalnoy/nestedset`'s `NodeTrait` (e.g. `Collection`) triggers a mutator that queries the database *before* the table prefix is applied, throwing "relation does not exist". Use `appendToNode()`/`saveAsRoot()` instead of setting `parent_id` directly. See `MigrateImport\Shopify\Resolvers\CollectionResolver` for the pattern.
- **Don't trust a schema read from memory — re-check the actual migration/model file.** `products.brand` was assumed to be a plain string column based on an earlier read; it's actually `brand_id`, a real FK to a `Brand` model. Verify column names against the live `Schema::getColumnListing()` or the actual migration file, not recollection.
- **Lunar's default `Language` may not be `en`.** Don't hardcode `app()->getLocale()` for `TranslatedText`/translatable fields — use `Lunar\Models\Language::getDefault()->code` (wrapped here as `MigrateImport\DefaultLocale::code()`). A mismatch means data saves under the wrong locale key and silently doesn't render in the panel.
- **`attribute_data` is not a free-form array.** Values must be `Lunar\Base\FieldType` instances (`Text`, `TranslatedText`, `Number`, etc.), and the handle must be mapped to the product's `ProductType` via `mappedAttributes()`/`productAttributes()` — otherwise the value is silently dropped or won't render in the panel.
- **New `ProductType`s start with zero mapped attributes.** Creating one via `ProductType::create()` alone means `name`/`description` won't work until you `attach()` the existing system attributes to it.
- **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness.
- **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead.
- **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken.
+150
View File
@@ -0,0 +1,150 @@
# Shopify → Lunar product import
Findings from comparing a real Shopify product export CSV against Lunar's schema (`vendor/lunarphp/core`), plus the resulting implementation plan for `MigrateImport\Shopify\ShopifyExportImporter`.
## Idempotency problem
Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run.
**Solution: a dedicated `import_mappings` table**, not columns on Lunar's core tables.
```php
Schema::create($this->prefix.'import_mappings', function (Blueprint $table) {
$table->id();
$table->string('source'); // 'shopify'
$table->string('source_type'); // 'product', 'variant', 'image', 'collection', 'tag'
$table->string('external_id'); // Shopify handle, or handle+position for variants/images
$table->morphs('model'); // model_type + model_id -> Product, ProductVariant, Asset, Collection, Tag
$table->timestamps();
$table->unique(['source', 'source_type', 'external_id']);
});
```
Every resolver looks up `import_mappings` by `(source, source_type, external_id)` first — update if found, create + insert mapping row if not.
## CSV shape
Shopify's export is flat: one row per (product × variant × extra image), with `Handle` repeated across all rows belonging to the same product. Only the first row per handle carries `Title`/`Body (HTML)`/`Vendor`/etc — later rows are variant-only or pure image rows (no `Option1 Value` at all).
The importer must first group consecutive CSV rows by `Handle` into `{ handle, productFields, variants: [...], images: [...] }` before touching Lunar.
## Field mapping: CSV → Lunar
| CSV column | Lunar equivalent | Notes |
|---|---|---|
| `Title`, `Body (HTML)` | `products.attribute_data` (JSON, translatable) | goes into attribute_data, not a flat column |
| `Vendor` | `products.brand` (plain string column) | direct passthrough |
| `Product Category` | `Collection` (nested set) inside a `CollectionGroup` | Google-taxonomy breadcrumb (e.g. `Toys & Games > Toys > Bath Toys`) — parse into a nested Collection chain, product attached to the deepest node. See "Collections" below. |
| `Type` | `product_type_id` (required FK) | must resolve/create a `ProductType`; needs a default fallback (e.g. "General") |
| `Tags` | `Tag` model (separate, not built into Product) | split CSV string, find/create, attach |
| `Published` / `Status` | `products.status` (plain string, indexed) | direct mapping |
| `Option1/2/3 Name+Value` | `product_options` + `product_option_values` + variant pivot | find/create option (by name), find/create value (by option+value), attach to variant |
| `Variant SKU` | `product_variants.sku` | direct |
| `Variant Grams` | `product_variants` dimensions (`$table->dimensions()`) | needs unit conversion check — macro likely adds `weight_value`/`weight_unit`, not raw grams |
| `Variant Inventory Qty` | `product_variants.stock` | direct |
| `Variant Price` / `Compare At Price` | `prices` table (polymorphic `priceable`, per-currency, per-customer-group) | **not a variant column** — create a `Price` row per variant per currency |
| `Cost per item` | *(nothing)* | Lunar has no cost-price field — stash in `attribute_data` as a custom attribute, in scope for v1 |
| `Variant Barcode` | closest is `gtin`/`ean` on variant | ambiguous, needs a mapping rule |
| `Variant Requires Shipping` | `product_variants.shippable` (boolean) | direct |
| `Variant Taxable` | `tax_class_id` (required FK, not boolean) | must resolve to an actual `TaxClass` row (e.g. "Standard" vs "None") |
| `Image Src` (+ image-only rows) | `Asset` model + Spatie MediaLibrary, ordered via `media_product_variant` pivot (`position`) | see "Images" below — file is local, not a URL to download |
| `SEO Title` / `SEO Description` | *(nothing structured)* | stash in `attribute_data` as custom attributes, in scope for v1 |
| Metafield columns (see below) | *(nothing)* | **all empty across every row in the sample export — skip entirely** |
| `Handle` | `Url` model (`urls` table, polymorphic, per-language slug) | create a `Url` row per product (slug = handle), not a plain column |
## Collections — Lunar supports multiple collections per product
Lunar's `collection_product` table is a plain many-to-many pivot (`collection_id`, `product_id`, `position`) with no uniqueness constraint tying a product to a single collection. A `Product` can belong to any number of `Collection`s simultaneously, and `Collection`s themselves are a nested-set tree (`NodeTrait`, `_lft`/`_rgt`/`parent_id`) scoped inside a `CollectionGroup` (`collection_group_id`).
This matters for the importer in two ways:
- **The breadcrumb import doesn't have to be exclusive.** Attaching a product to `Toys & Games > Toys > Bath Toys > Rubber Duckies` doesn't prevent it from also being attached to a separately-curated collection (e.g. a manual "Best Sellers" collection) — no conflict, since it's a plain pivot.
- **Resolve the breadcrumb into whichever `CollectionGroup` fits the target site's structure** — the known Shopify category tree can be parsed straight into it; no need for a separate import-only group unless you specifically want to isolate/purge imported collections independently later.
- Only the **deepest node** of the breadcrumb needs the product attached directly — Lunar's nested-set ancestry (`getBreadcrumbAttribute()`) already exposes the full parent chain via `$collection->ancestors`, so there's no need to attach the product to every intermediate level.
Resolver logic: split `Product Category` on `>`, trim each segment, walk down creating/reusing `Collection` nodes as a chain (`parent_id` chained) within the fixed import `CollectionGroup`, then attach the product only to the final (leaf) `Collection` via `collection_product`.
## Images — the export includes a local files folder
The Shopify export isn't just the CSV — it also produces a folder of the actual image files, named by UUID (not by the original filename or `Image Src` URL). The `Image Src` column in the CSV is the *original* Shopify CDN URL, which is **not** how the file is named on disk in the export folder.
This changes the AssetResolver from an HTTP-download step to a local-file-lookup step:
- The importer needs a way to map each CSV `Image Src` row to its corresponding UUID-named file in the export folder — check whether the export also provides a manifest/mapping file, or whether the mapping has to be inferred from row order per `Handle` (i.e. Nth image row for a handle = Nth file in that handle's image set, if the export preserves ordering).
- Once resolved, the local file is what gets added to the `Asset` + Spatie MediaLibrary (`addMedia($localPath)`), not a downloaded copy of the CDN URL — no HTTP client / retry / timeout handling needed for images, but a **file-existence check** is needed (missing/renamed file in the export folder should not silently fail the whole product import).
- `import_mappings` for images should still key on something stable from the CSV (e.g. `Image Src` URL, or `handle + position`) rather than the UUID filename, since the UUID is regenerated on every fresh export and isn't a stable external ID across re-imports.
## Metafield columns — confirmed empty, skipped
These are Shopify's auto-generated category-specific metafields (populated once a product has a `Product Category` assigned). In the sample export they were empty for effectively every row (a few had `color-pattern`, `material`, or `target-gender` populated). **Decision: skip all of these for v1**, since none carry data worth preserving in this dataset.
```
Product rating count (product.metafields.reviews.rating_count)
Age group (product.metafields.shopify.age-group)
Barware material (product.metafields.shopify.barware-material)
Board game mechanics (product.metafields.shopify.board-game-mechanics)
Color (product.metafields.shopify.color-pattern)
Construction set theme (product.metafields.shopify.construction-set-theme)
Decoration material (product.metafields.shopify.decoration-material)
Dexterity skills (product.metafields.shopify.dexterity-skills)
Dice shape (product.metafields.shopify.dice-shape)
Difficulty level (product.metafields.shopify.difficulty-level)
Doll/Playset features (product.metafields.shopify.doll-playset-features)
Finish (product.metafields.shopify.finish)
Gameplay skills (product.metafields.shopify.gameplay-skills)
Hardware material (product.metafields.shopify.hardware-material)
Material (product.metafields.shopify.material)
Pet trainer type (product.metafields.shopify.pet-trainer-type)
Plant class (product.metafields.shopify.plant-class)
Plant name (product.metafields.shopify.plant-name)
Puzzle theme (product.metafields.shopify.puzzle-theme)
Puzzle type (product.metafields.shopify.puzzle-type)
Recommended age group (product.metafields.shopify.recommended-age-group)
Shape (product.metafields.shopify.shape)
Stationery binding type (product.metafields.shopify.stationery-binding-type)
Suitable space (product.metafields.shopify.suitable-space)
Target gender (product.metafields.shopify.target-gender)
Theme (product.metafields.shopify.theme)
Toy/Game material (product.metafields.shopify.toy-game-material)
Usage type (product.metafields.shopify.usage-type)
Vase shape (product.metafields.shopify.vase-shape)
```
## Resolvers, in dependency order
Each resolver is idempotent: find-by-natural-key-or-create, checking `import_mappings` where applicable.
1. **TaxClassResolver** — maps `Variant Taxable` (bool) to a fixed `TaxClass` ("Standard" vs "None"), not a per-row lookup.
2. **ProductTypeResolver** — maps CSV `Type` to `ProductType`; falls back to a default (e.g. "General") since `product_type_id` is required.
3. **BrandResolver**`Vendor``products.brand`; likely a passthrough, not a real resolver, since it's a plain string column.
4. **TagResolver** — splits `Tags`, finds/creates `Tag` rows, attaches to product.
5. **CollectionResolver** — parses `Product Category` breadcrumb into a nested `Collection` chain within the target `CollectionGroup` (find/create per level), attaches product only to the deepest (leaf) Collection. Does not touch or conflict with any other collections the product may already belong to — a product can be in multiple collections at once.
6. **ProductOptionResolver** — for each `Option1/2/3 Name`, find/create `ProductOption` + `ProductOptionValue`, reused across products.
7. **AssetResolver** — resolves each CSV image row to its local UUID-named file in the export's images folder (see "Images" above), creates `Asset` + Spatie media from that local file, attaches to product and/or specific variants with `position` ordering.
8. **PriceResolver** — creates `Price` row(s) per variant per currency from `Variant Price` / `Variant Compare At Price`.
## Orchestration order per product group
```
1. Resolve/create ProductType, TaxClass, Brand
2. Create or update Product (import_mappings: source_type='product', external_id=handle)
- write Title/Body, Cost per item, SEO title/description into attribute_data
3. Create/attach Url (slug = handle)
4. Resolve/attach Tags
5. Resolve/attach Collection chain (parsed from Product Category breadcrumb, within the target CollectionGroup; attach product to leaf Collection only)
6. For each Option1/2/3 Name -> resolve ProductOption
7. For each variant row:
- resolve ProductOptionValues for that variant's option combination
- create or update ProductVariant (import_mappings: source_type='variant', external_id=handle+variant-index)
- attach ProductOptionValues to variant
- create/update Price row(s)
8. For each image row:
- resolve the CSV row to its local UUID-named file in the export's images folder; skip with a logged warning if the file is missing
- resolve/create Asset from the local file (import_mappings: source_type='image', external_id=image URL or handle+position — not the UUID filename, which is regenerated per export)
- attach to Product, and to specific ProductVariant if the image is variant-scoped
```
## Explicitly out of scope for v1
- Metafield columns (confirmed empty — see above).
+249
View File
@@ -0,0 +1,249 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Facades\DB;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Attribute;
use Lunar\Models\AttributeGroup;
use Lunar\Models\Channel;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Country;
use Lunar\Models\Currency;
use Lunar\Models\CustomerGroup;
use Lunar\Models\Language;
use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Lunar\Models\TaxClass;
use Lunar\Models\TaxZone;
/**
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
* confirmation, admin creation, GitHub star), so it's safe to run unattended from
* the entrypoint on every boot. Migrations already run as a separate, earlier
* entrypoint step; the admin user is created manually.
*/
class InstallLunarCommand extends Command
{
protected $signature = 'lunar:install';
protected $description = 'Seed the default Lunar store data (countries, channel, currency, tax zone, attributes, product type)';
public function handle(): void
{
$this->components->info('Seeding default Lunar store data...');
if (! Country::count()) {
$this->components->info('Importing countries');
$this->call('lunar:import:address-data');
}
DB::transaction(function () {
if (! Channel::whereDefault(true)->exists()) {
$this->components->info('Setting up default channel');
Channel::create([
'name' => 'Webstore',
'handle' => 'webstore',
'default' => true,
'url' => 'http://localhost',
]);
}
if (! Language::count()) {
$this->components->info('Adding default language');
Language::create([
'code' => 'en',
'name' => 'English',
'default' => true,
]);
}
if (! Currency::whereDefault(true)->exists()) {
$this->components->info('Adding a default currency (USD)');
Currency::create([
'code' => 'USD',
'name' => 'US Dollar',
'exchange_rate' => 1,
'decimal_places' => 2,
'default' => true,
'enabled' => true,
]);
}
if (! CustomerGroup::whereDefault(true)->exists()) {
$this->components->info('Adding a default customer group.');
CustomerGroup::create([
'name' => 'Retail',
'handle' => 'retail',
'default' => true,
]);
}
if (! CollectionGroup::count()) {
$this->components->info('Adding an initial collection group');
CollectionGroup::create([
'name' => 'Main',
'handle' => 'main',
]);
}
if (! TaxClass::count()) {
$this->components->info('Adding a default tax class.');
TaxClass::create([
'name' => 'Default Tax Class',
'default' => true,
]);
}
if (! TaxZone::count()) {
$this->components->info('Adding a default tax zone.');
$taxZone = TaxZone::create([
'name' => 'Default Tax Zone',
'zone_type' => 'country',
'price_display' => 'tax_exclusive',
'default' => true,
'active' => true,
]);
$taxZone->countries()->createMany(
Country::get()->map(fn ($country) => [
'country_id' => $country->id,
])
);
}
if (! Attribute::count()) {
$this->components->info('Setting up initial attributes');
$group = AttributeGroup::create([
'attributable_type' => Product::morphName(),
'name' => collect([
'en' => 'Details',
]),
'handle' => 'details',
'position' => 1,
]);
$collectionGroup = AttributeGroup::create([
'attributable_type' => Collection::morphName(),
'name' => collect([
'en' => 'Details',
]),
'handle' => 'collection_details',
'position' => 1,
]);
Attribute::create([
'attribute_type' => 'product',
'attribute_group_id' => $group->id,
'position' => 1,
'name' => [
'en' => 'Name',
],
'handle' => 'name',
'section' => 'main',
'type' => TranslatedText::class,
'required' => true,
'default_value' => null,
'configuration' => [
'richtext' => false,
],
'system' => true,
'description' => [
'en' => '',
],
]);
Attribute::create([
'attribute_type' => 'collection',
'attribute_group_id' => $collectionGroup->id,
'position' => 1,
'name' => [
'en' => 'Name',
],
'handle' => 'name',
'section' => 'main',
'type' => TranslatedText::class,
'required' => true,
'default_value' => null,
'configuration' => [
'richtext' => false,
],
'system' => true,
'description' => [
'en' => '',
],
]);
Attribute::create([
'attribute_type' => 'product',
'attribute_group_id' => $group->id,
'position' => 2,
'name' => [
'en' => 'Description',
],
'handle' => 'description',
'section' => 'main',
'type' => TranslatedText::class,
'required' => false,
'default_value' => null,
'configuration' => [
'richtext' => true,
],
'system' => false,
'description' => [
'en' => '',
],
]);
Attribute::create([
'attribute_type' => 'collection',
'attribute_group_id' => $collectionGroup->id,
'position' => 2,
'name' => [
'en' => 'Description',
],
'handle' => 'description',
'section' => 'main',
'type' => TranslatedText::class,
'required' => false,
'default_value' => null,
'configuration' => [
'richtext' => true,
],
'system' => false,
'description' => [
'en' => '',
],
]);
}
if (! ProductType::count()) {
$this->components->info('Adding a product type.');
$type = ProductType::create([
'name' => 'Stock',
]);
$type->mappedAttributes()->attach(
Attribute::whereAttributeType(
Product::morphName()
)->get()->pluck('id')
);
}
});
$this->components->info('Publishing Filament assets');
$this->call('filament:assets');
$this->components->info('Lunar default data seeded.');
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\RunMigrateImportJob;
class MigrateImportCommand extends Command
{
protected $signature = "boboko:migrate:import
{--source= : The source platform to import from}
{--type= : The import mechanism for the chosen source}
{--file= : Path to the export file (when type is export)}";
protected $description = "Migrate data (products, customers, etc.) from an external source";
// Supported sources. Only "shopify" is wired up for now.
private const SOURCES = [
"shopify",
// "woocommerce",
];
// Supported import types per source. Only "export" is wired up for now.
private const TYPES = [
"export",
// "api",
];
public function handle(): void
{
$source = $this->option("source")
?? $this->choice("Select a source:", self::SOURCES, 0);
$type = $this->option("type")
?? $this->choice("Select an import type:", self::TYPES, 0);
if ($type === "api") {
$credentials = [
"api_key" => $this->secret("API key:"),
"api_secret" => $this->secret("API secret:"),
];
$filePath = null;
} else {
$importsPath = storage_path("app/private/imports");
$filePath = $this->option("file")
?? $this->resolveImportPath($importsPath, $this->ask("Path to the export file, relative to {$importsPath}:"));
while (blank($filePath) || ! is_file($filePath)) {
if (filled($filePath)) {
$this->error("File not found: {$filePath}");
}
$filePath = $this->resolveImportPath(
$importsPath,
$this->ask("Path to the export file, relative to {$importsPath}:"),
);
}
$credentials = null;
}
$spec = new ImportSpec(
source: $source,
type: $type,
filePath: $filePath,
credentials: $credentials,
);
RunMigrateImportJob::dispatch($spec);
$this->info("Import queued.");
}
// Answers are relative to storage/app/private/imports (e.g. "shopify" or
// "shopify/products_export.csv"); absolute paths are used as-is. A
// directory answer picks the first CSV file found inside it.
private function resolveImportPath(string $importsPath, ?string $answer): ?string
{
if (blank($answer)) {
return $answer;
}
$path = str_starts_with($answer, "/") ? $answer : "{$importsPath}/{$answer}";
if (is_dir($path)) {
$csv = collect(glob("{$path}/*.csv"))->first();
return $csv ?? $path;
}
return $path;
}
}
+2 -8
View File
@@ -3,12 +3,12 @@
namespace Modules\Core; namespace Modules\Core;
use Filament\Contracts\Plugin; use Filament\Contracts\Plugin;
use Filament\Navigation\NavigationItem;
use Filament\Panel; use Filament\Panel;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\StaffResource; use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff; use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel; use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Shipping\ShippingPlugin;
use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Auth\Mail\InviteMail;
@@ -27,13 +27,7 @@ class CorePlugin implements Plugin
->brandLogo(asset('static/logos/core/boboko-logo.svg')) ->brandLogo(asset('static/logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg')) ->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
->login(Login::class) ->login(Login::class)
->navigationItems([ ->plugin(ShippingPlugin::make());
NavigationItem::make("Lucent")
->url("/lucent")
->icon("heroicon-o-sun")
->group("Content")
->sort(1),
]);
LunarPanel::extensions([ LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class, StaffResource::class => StaffResourceExtension::class,
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\MigrateImport;
use Lunar\Models\Language;
class DefaultLocale
{
public static function code(): string
{
return Language::getDefault()->code;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\MigrateImport;
class ImportSpec
{
public function __construct(
public readonly string $source,
public readonly string $type,
public readonly ?string $filePath = null,
public readonly ?array $credentials = null,
) {
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace Modules\Core\MigrateImport;
interface Importer
{
public function import(ImportSpec $spec): void;
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\MigrateImport;
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
class ImporterFactory
{
public static function make(ImportSpec $spec): Importer
{
return match ([$spec->source, $spec->type]) {
["shopify", "export"] => new ShopifyExportImporter(),
// ["shopify", "api"] => new ShopifyApiImporter(),
// ["woocommerce", "export"] => new WooCommerceExportImporter(),
// ["woocommerce", "api"] => new WooCommerceApiImporter(),
default => throw new \InvalidArgumentException(
"No importer available for source \"{$spec->source}\" with type \"{$spec->type}\".",
),
};
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\MigrateImport\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ImportMapping extends Model
{
protected $guarded = [];
public function model(): MorphTo
{
return $this->morphTo();
}
public static function resolve(string $source, string $sourceType, string $externalId): ?Model
{
return static::query()
->where('source', $source)
->where('source_type', $sourceType)
->where('external_id', $externalId)
->first()
?->model;
}
public static function record(string $source, string $sourceType, string $externalId, Model $model): self
{
return static::query()->updateOrCreate(
[
'source' => $source,
'source_type' => $sourceType,
'external_id' => $externalId,
],
[
'model_type' => $model->getMorphClass(),
'model_id' => $model->getKey(),
],
);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\MigrateImport;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RunMigrateImportJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly ImportSpec $spec,
) {
}
public function handle(): void
{
$importer = ImporterFactory::make($this->spec);
$importer->import($this->spec);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
class ProductGroup
{
/** @var array<int, array<string, string>> */
public array $variantRows = [];
/** @var array<int, array<string, string>> */
public array $imageRows = [];
public function __construct(
public readonly string $handle,
public readonly array $productRow,
) {
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Product;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class AssetResolver
{
public function resolve(Product $product, string $localFilePath, int $position): ?Media
{
if (! is_file($localFilePath)) {
return null;
}
return $product->addMedia($localFilePath)
->preservingOriginal()
->withCustomProperties(['position' => $position])
->toMediaCollection(config('lunar.media.collection'));
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Brand;
class BrandResolver
{
public function resolve(?string $vendor): ?Brand
{
$vendor = trim((string) $vendor);
if ($vendor === '') {
return null;
}
return Brand::firstOrCreate(['name' => $vendor]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Product;
use Modules\Core\MigrateImport\DefaultLocale;
class CollectionResolver
{
public function resolve(Product $product, CollectionGroup $group, ?string $categoryPath): void
{
$segments = collect(explode('>', (string) $categoryPath))
->map(fn (string $segment) => trim($segment))
->filter();
if ($segments->isEmpty()) {
return;
}
$parent = null;
foreach ($segments as $segment) {
$parent = $this->findChild($group, $parent, $segment) ?? $this->create($group, $parent, $segment);
}
$product->collections()->syncWithoutDetaching([$parent->id]);
}
private function findChild(CollectionGroup $group, ?Collection $parent, string $name): ?Collection
{
return Collection::query()
->where('collection_group_id', $group->id)
->where('parent_id', $parent?->id)
->get()
->first(fn (Collection $collection) => $collection->translateAttribute('name') === $name);
}
private function create(CollectionGroup $group, ?Collection $parent, string $name): Collection
{
$collection = new Collection([
'collection_group_id' => $group->id,
'attribute_data' => [
'name' => new TranslatedText(collect([
DefaultLocale::code() => new Text($name),
])),
],
]);
// Mass-assigning parent_id triggers NodeTrait's setParentIdAttribute
// mutator before BaseModel's constructor has prefixed the table,
// causing an "undefined table" query. appendToNode()/saveAsRoot()
// avoid that mutator entirely.
if ($parent) {
$collection->appendToNode($parent)->save();
} else {
$collection->saveAsRoot();
}
return $collection;
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\FieldTypes\Number;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Attribute;
use Lunar\Models\AttributeGroup;
use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale;
class ImportAttributeResolver
{
// Attributes Lunar doesn't ship by default but this importer needs,
// with the field type each one should use.
public const EXTRA_ATTRIBUTES = [
'cost_per_item' => ['label' => 'Cost per item', 'type' => Number::class],
'seo_title' => ['label' => 'SEO Title', 'type' => TranslatedText::class],
'seo_description' => ['label' => 'SEO Description', 'type' => TranslatedText::class],
];
public function ensureMapped(ProductType $productType): void
{
$mappedHandles = $productType->productAttributes()->pluck('handle')->all();
$missing = array_diff(array_keys(self::EXTRA_ATTRIBUTES), $mappedHandles);
if (empty($missing)) {
return;
}
$group = $this->importAttributeGroup();
$nextPosition = Attribute::where('attribute_group_id', $group->id)->max('position') + 1;
foreach ($missing as $handle) {
$definition = self::EXTRA_ATTRIBUTES[$handle];
$attribute = Attribute::firstOrCreate(
['attribute_type' => Product::morphName(), 'handle' => $handle],
[
'attribute_group_id' => $group->id,
'position' => $nextPosition++,
'name' => [DefaultLocale::code() => $definition['label']],
'section' => 'main',
'type' => $definition['type'],
'required' => false,
'default_value' => null,
'configuration' => $definition['type'] === TranslatedText::class
? ['richtext' => false]
: [],
'system' => false,
'description' => [DefaultLocale::code() => ''],
],
);
$productType->mappedAttributes()->syncWithoutDetaching([$attribute->id]);
}
}
private function importAttributeGroup(): AttributeGroup
{
return AttributeGroup::firstOrCreate(
['attributable_type' => Product::morphName(), 'handle' => 'import'],
[
'name' => [DefaultLocale::code() => 'Additional Details'],
'position' => 100,
],
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Currency;
use Lunar\Models\Price;
use Lunar\Models\ProductVariant;
class PriceResolver
{
public function resolve(ProductVariant $variant, Currency $currency, float $price, ?float $comparePrice): Price
{
return Price::updateOrCreate(
[
'priceable_type' => $variant->getMorphClass(),
'priceable_id' => $variant->id,
'currency_id' => $currency->id,
'customer_group_id' => null,
],
[
'price' => (int) round($price * (10 ** $currency->decimal_places)),
'compare_price' => $comparePrice !== null
? (int) round($comparePrice * (10 ** $currency->decimal_places))
: null,
'min_quantity' => 1,
],
);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\FieldTypes\Number;
use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale;
class ProductAttributeResolver
{
/**
* @param array<string, ?string> $values keyed by attribute handle, e.g. ['name' => ..., 'description' => ...]
*/
public function resolve(ProductType $productType, array $values): array
{
$mappedHandles = $productType->productAttributes()->pluck('handle')->all();
$attributeData = [];
foreach ($values as $handle => $value) {
if (! in_array($handle, $mappedHandles, true)) {
continue;
}
if (trim((string) $value) === '') {
continue;
}
$attributeData[$handle] = $this->fieldFor($handle, $value);
}
return $attributeData;
}
private function fieldFor(string $handle, string $value): Number|TranslatedText
{
$type = ImportAttributeResolver::EXTRA_ATTRIBUTES[$handle]['type'] ?? TranslatedText::class;
if ($type === Number::class) {
return new Number((float) $value);
}
return new TranslatedText(collect([
DefaultLocale::code() => new Text($value),
]));
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Illuminate\Support\Str;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Modules\Core\MigrateImport\DefaultLocale;
class ProductOptionResolver
{
public function resolveOption(string $name): ProductOption
{
// Match by slugified name (== handle), not raw name, so Shopify's
// inconsistent casing across products ("Size" vs "size") resolves to
// the same option instead of creating a near-duplicate.
$handle = Str::slug($name) ?: 'option';
return ProductOption::query()->firstOrCreate(
['handle' => $handle],
[
'name' => [DefaultLocale::code() => $name],
'shared' => true,
],
);
}
public function resolveValue(ProductOption $option, string $value): ProductOptionValue
{
$slug = Str::slug($value) ?: 'value';
// Query fresh rather than $option->values: that relation is lazily
// cached on first access, so within one product's variant loop it
// would miss a value created earlier in the same loop, creating a
// duplicate for the same option+value pair.
$existing = ProductOptionValue::query()
->where('product_option_id', $option->id)
->get()
->first(fn (ProductOptionValue $optionValue) => Str::slug($optionValue->translate('name')) === $slug);
return $existing ?? ProductOptionValue::create([
'product_option_id' => $option->id,
'name' => [DefaultLocale::code() => $value],
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Attribute;
use Lunar\Models\Product;
use Lunar\Models\ProductType;
class ProductTypeResolver
{
private const DEFAULT_NAME = 'General';
public function resolve(?string $type): ProductType
{
$name = trim((string) $type) !== '' ? trim($type) : self::DEFAULT_NAME;
$existing = ProductType::where('name', $name)->first();
if ($existing) {
return $existing;
}
$productType = ProductType::create(['name' => $name]);
$productType->mappedAttributes()->attach(
Attribute::whereAttributeType(Product::morphName())->pluck('id'),
);
return $productType;
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Lunar\Models\Product;
use Lunar\Models\Tag;
class TagResolver
{
public function resolve(Product $product, ?string $tags): void
{
$values = collect(explode(',', (string) $tags))
->map(fn (string $tag) => trim($tag))
->filter();
$this->sync($product, $values);
}
private function sync(Product $product, Collection $tags): void
{
$tagIds = $tags
->map(fn (string $tag) => Str::upper($tag))
->map(fn (string $tag) => Tag::firstOrCreate(['value' => $tag])->id);
$product->tags()->sync($tagIds);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\TaxClass;
class TaxClassResolver
{
public function resolve(bool $taxable): TaxClass
{
return TaxClass::getDefault();
}
}
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
class ShopifyCsvReader
{
/**
* @return array<int, ProductGroup>
*/
public function read(string $csvPath): array
{
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
}
$headers = fgetcsv($handle);
/** @var array<string, ProductGroup> $groups */
$groups = [];
$order = [];
while (($row = fgetcsv($handle)) !== false) {
$data = array_combine($headers, $row);
$productHandle = trim($data['Handle'] ?? '');
if ($productHandle === '') {
continue;
}
if (! isset($groups[$productHandle])) {
$groups[$productHandle] = new ProductGroup($productHandle, $data);
$order[] = $productHandle;
}
$group = $groups[$productHandle];
$hasVariantData = trim((string) ($data['Option1 Value'] ?? '')) !== ''
|| trim((string) ($data['Variant SKU'] ?? '')) !== '';
if ($hasVariantData) {
$group->variantRows[] = $data;
}
if (trim((string) ($data['Image Src'] ?? '')) !== '') {
$group->imageRows[] = $data;
}
}
fclose($handle);
return array_map(fn (string $handle) => $groups[$handle], $order);
}
}
@@ -0,0 +1,238 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Currency;
use Lunar\Models\Language;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Lunar\Models\Url;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\Importer;
use Modules\Core\MigrateImport\Models\ImportMapping;
use Modules\Core\MigrateImport\Shopify\Resolvers\AssetResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\BrandResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\CollectionResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ImportAttributeResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\PriceResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductAttributeResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver;
class ShopifyExportImporter implements Importer
{
private const SOURCE = 'shopify';
public function __construct(
private readonly ShopifyCsvReader $csvReader = new ShopifyCsvReader(),
private readonly TaxClassResolver $taxClassResolver = new TaxClassResolver(),
private readonly ProductTypeResolver $productTypeResolver = new ProductTypeResolver(),
private readonly BrandResolver $brandResolver = new BrandResolver(),
private readonly TagResolver $tagResolver = new TagResolver(),
private readonly CollectionResolver $collectionResolver = new CollectionResolver(),
private readonly ProductOptionResolver $productOptionResolver = new ProductOptionResolver(),
private readonly AssetResolver $assetResolver = new AssetResolver(),
private readonly PriceResolver $priceResolver = new PriceResolver(),
private readonly ImportAttributeResolver $importAttributeResolver = new ImportAttributeResolver(),
private readonly ProductAttributeResolver $productAttributeResolver = new ProductAttributeResolver(),
) {
}
public function import(ImportSpec $spec): void
{
$groups = $this->csvReader->read($spec->filePath);
$imagesPath = dirname($spec->filePath).'/files';
$collectionGroup = CollectionGroup::firstOrCreate(
['handle' => 'shopify'],
['name' => 'Shopify'],
);
$currency = Currency::getDefault();
foreach ($groups as $group) {
$this->importProduct($group, $imagesPath, $collectionGroup, $currency);
}
}
private function importProduct(
ProductGroup $group,
string $imagesPath,
CollectionGroup $collectionGroup,
Currency $currency,
): void {
$row = $group->productRow;
$taxClass = $this->taxClassResolver->resolve(
filter_var($row['Variant Taxable'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
);
$productType = $this->productTypeResolver->resolve($row['Type'] ?? null);
$brand = $this->brandResolver->resolve($row['Vendor'] ?? null);
$this->importAttributeResolver->ensureMapped($productType);
$attributeData = $this->productAttributeResolver->resolve($productType->fresh(), [
'name' => $row['Title'] ?? '',
'description' => $row['Body (HTML)'] ?? '',
'cost_per_item' => $row['Cost per item'] ?? '',
'seo_title' => $row['SEO Title'] ?? '',
'seo_description' => $row['SEO Description'] ?? '',
]);
$existing = ImportMapping::resolve(self::SOURCE, 'product', $group->handle);
$product = $existing instanceof Product ? $existing : new Product();
$product->product_type_id = $productType->id;
$product->status = filter_var($row['Published'] ?? 'true', FILTER_VALIDATE_BOOLEAN) ? 'published' : 'draft';
$product->brand_id = $brand?->id;
$product->attribute_data = array_merge($product->attribute_data?->toArray() ?? [], $attributeData);
$product->save();
ImportMapping::record(self::SOURCE, 'product', $group->handle, $product);
Url::updateOrCreate(
[
'element_type' => $product->getMorphClass(),
'element_id' => $product->id,
'language_id' => Language::getDefault()->id,
],
[
'slug' => $group->handle,
'default' => true,
],
);
$this->tagResolver->resolve($product, $row['Tags'] ?? null);
$this->collectionResolver->resolve($product, $collectionGroup, $row['Product Category'] ?? null);
$options = $this->attachOptions($product, $row);
foreach ($group->variantRows as $index => $variantRow) {
$this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options);
}
foreach ($group->imageRows as $index => $imageRow) {
$this->importImage($product, $group->handle, $index, $imageRow, $imagesPath);
}
}
/**
* @return array<int, \Lunar\Models\ProductOption>
*/
private function attachOptions(Product $product, array $row): array
{
$options = [];
foreach ([1, 2, 3] as $position) {
$name = trim((string) ($row["Option{$position} Name"] ?? ''));
if ($name === '' || $name === 'Title') {
continue;
}
$option = $this->productOptionResolver->resolveOption($name);
$product->productOptions()->syncWithoutDetaching([$option->id => ['position' => $position]]);
$options[$position] = $option;
}
return $options;
}
private function importVariant(
Product $product,
string $handle,
int $index,
array $row,
\Lunar\Models\TaxClass $taxClass,
Currency $currency,
array $options,
): void {
$externalId = "{$handle}#{$index}";
$existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId);
$variant = $existing instanceof ProductVariant ? $existing : new ProductVariant();
$variant->product_id = $product->id;
$variant->tax_class_id = $taxClass->id;
$variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null;
$variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0);
$variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN);
$variant->save();
ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant);
foreach ($options as $position => $option) {
$value = trim((string) ($row["Option{$position} Value"] ?? ''));
if ($value === '') {
continue;
}
$optionValue = $this->productOptionResolver->resolveValue($option, $value);
$variant->values()->syncWithoutDetaching([$optionValue->id]);
}
$price = (float) ($row['Variant Price'] ?? 0);
$comparePrice = trim((string) ($row['Variant Compare At Price'] ?? '')) !== ''
? (float) $row['Variant Compare At Price']
: null;
$this->priceResolver->resolve($variant, $currency, $price, $comparePrice);
}
private function importImage(
Product $product,
string $handle,
int $index,
array $row,
string $imagesPath,
): void {
$externalId = $row['Image Src'] ?: "{$handle}#image-{$index}";
$position = (int) ($row['Image Position'] ?? $index + 1);
if (ImportMapping::resolve(self::SOURCE, 'image', $externalId)) {
return;
}
$localFile = $this->findLocalFile($imagesPath, $row['Image Src']);
if ($localFile === null) {
Log::warning('Shopify import: image file not found', [
'handle' => $handle,
'image_src' => $row['Image Src'],
]);
return;
}
$media = $this->assetResolver->resolve($product, $localFile, $position);
if ($media) {
ImportMapping::record(self::SOURCE, 'image', $externalId, $product);
}
}
private function findLocalFile(string $imagesPath, string $imageSrc): ?string
{
$imagesPath = rtrim($imagesPath, '/');
$filename = basename(parse_url($imageSrc, PHP_URL_PATH) ?? '');
// Shopify CDN filenames often embed a UUID (e.g. "1_ab5a6923-...-b130c6.jpg").
// Export folders name files by that UUID alone, so try matching on it
// before falling back to an exact filename match.
if (preg_match('/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i', $filename, $matches)) {
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$byUuid = "{$imagesPath}/{$matches[0]}".($extension ? ".{$extension}" : '');
if (is_file($byUuid)) {
return $byUuid;
}
}
$byFilename = "{$imagesPath}/{$filename}";
return is_file($byFilename) ? $byFilename : null;
}
}
+6 -1
View File
@@ -8,6 +8,8 @@ use Modules\Core\Command\AnonymizeCommand;
use Modules\Core\Command\ExportCleanupCommand; use Modules\Core\Command\ExportCleanupCommand;
use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ExportCommand;
use Modules\Core\Command\ImportCommand; use Modules\Core\Command\ImportCommand;
use Modules\Core\Command\InstallLunarCommand;
use Modules\Core\Command\MigrateImportCommand;
class CoreServiceProvider extends ServiceProvider class CoreServiceProvider extends ServiceProvider
{ {
@@ -32,7 +34,10 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets'); ], 'core-assets');
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class]); $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
//Overriding lunar:install
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
} }
} }
} }