Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7035d6782 | ||
|
|
4c0974bf84 | ||
|
|
4e15d8ef8c | ||
|
|
1c7efc6e4d | ||
|
|
59c57b37fc | ||
|
|
37b49963f6 | ||
|
|
050204f063 | ||
|
|
c0ae9d8996 | ||
|
|
cc1cf6ea7f |
@@ -4,6 +4,94 @@ 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.20.0] - 2026-09-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `Modules\Core\Catalog\Support\ProductFilterBuilder::withVisibility()` — every storefront
|
||||||
|
product read (`ProductService::list()`/`getById()`/`getBySlug()`/`random()`/`facets()`/
|
||||||
|
`priceRange()`, and `ProductSearchService`) now excludes `status = "draft"` products unless
|
||||||
|
`APP_DEBUG` is true. Previously nothing filtered by status anywhere in this service — a draft
|
||||||
|
product was fully visible on the storefront in every environment, always.
|
||||||
|
- Per-product custom input fields (`Modules\Core\Catalog\Models\Product::$custom_fields`) — a
|
||||||
|
repeater on the product edit form lets a merchant define extra input a shopper fills in on
|
||||||
|
that product's page before adding it to cart (a reference photo upload, personalization
|
||||||
|
text, etc.), each field a `{key, type: text|textarea|file, label, required}` entry.
|
||||||
|
Deliberately not a Lunar `ProductOption`: an option's values are a fixed, admin-authored list
|
||||||
|
that define variants, which doesn't fit "the shopper uploads their own unique photo."
|
||||||
|
Required a new first-party `Product` model (registered via `ModelManifest::replace()`) purely
|
||||||
|
to add a cast and `$fillable` entry Lunar's own base model doesn't have for this column — see
|
||||||
|
that class's own docblock for two real Lunar-integration bugs this surfaced (below).
|
||||||
|
- `boboko:wipe-catalog` (`Modules\Core\Command\WipeCatalogCommand`) — irreversibly deletes every
|
||||||
|
Product and everything that only exists because of a product (variants, variant prices,
|
||||||
|
product-option assignments, images/media, associations, the `ImportMapping` rows tying them
|
||||||
|
back to an external source, the Meilisearch index), leaving catalog structure other products
|
||||||
|
could still reference untouched (ProductOption/ProductOptionValue definitions, Brands,
|
||||||
|
Collections, Tags, Customer Groups). Gated by an OTP emailed to a real Staff account (reusing
|
||||||
|
`Auth\Services\OtpService`, the same mechanism admin login already uses) plus typing the exact
|
||||||
|
product count back — not a plain yes/no confirm.
|
||||||
|
- `Modules\Core\Auth\Services\OtpService::generateAndSend()` gained an optional `$purpose`
|
||||||
|
parameter (default `'login'`, fully backward compatible) — `OtpMail` picks its subject/intro
|
||||||
|
copy from a small fixed set of known purposes, so a destructive-command confirmation code
|
||||||
|
reads as "Confirm: Wipe Catalog," not the login flow's "Your login code."
|
||||||
|
- `Modules\Core\Catalog\Services\SkuBackfillService` — the actual backfill logic behind
|
||||||
|
`boboko:catalog:backfill-skus`, extracted so `MigrateImport\RunMigrateImportJob` can also call
|
||||||
|
it automatically right after a Shopify import (gated on `$spec->source === 'shopify'`, the
|
||||||
|
only source that creates variants at all) — no separate manual step needed after a migration.
|
||||||
|
- `ProductSort::Popularity` — sorts by a new `order_count` field Meilisearch now indexes per
|
||||||
|
product (trailing-year, physical order lines only, aggregated across a product's variants) —
|
||||||
|
the same "popular" definition Lunar's own admin dashboard "Popular Products" widget already
|
||||||
|
uses. Not wired into the storefront's sort dropdown yet; callable directly via
|
||||||
|
`ProductService::list(sort: ProductSort::Popularity)`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `MigrateImport\Shopify\ShopifyExportImporter` created every variant with Lunar's own column
|
||||||
|
default `purchasable = 'always'` (purchasable regardless of stock) rather than respecting the
|
||||||
|
real `Variant Inventory Qty` the import itself provides — now explicitly set to `'in_stock'`.
|
||||||
|
Forward-only; does not retroactively touch variants from a prior import run.
|
||||||
|
- `WipeCatalogCommand::wipe()` used `chunkById()` while deleting rows inside the loop — a known
|
||||||
|
pitfall where `chunkById()` re-queries "id > lastSeenId" every iteration, so deleting rows
|
||||||
|
shrinks the table out from under it and can silently skip products that were never actually
|
||||||
|
deleted at all. Fixed by always re-querying the first N remaining rows instead of advancing
|
||||||
|
an id cursor, so every product is visited exactly once regardless of how many are deleted out
|
||||||
|
from under the query as it goes.
|
||||||
|
- `WipeCatalogCommand` called `delete()`, not `forceDelete()`, on `Product`/`ProductVariant` —
|
||||||
|
both use `SoftDeletes`, so an "irreversible" wipe only trashed rows, leaving them sitting in
|
||||||
|
the table. Combined with the `chunkById` bug above, this left ~185 zero-variant ghost
|
||||||
|
`Product` rows in practice, which then crashed the admin's own global search (Lunar's
|
||||||
|
`ProductResource::getGlobalSearchResultDetails()` assumes every returned product — trashed
|
||||||
|
ones deliberately included, by Lunar's own design — has at least one variant). Fixed to
|
||||||
|
`forceDelete()`; the existing ghost rows were removed directly (none had live order/cart
|
||||||
|
references). `wipe()` also now clears `'image'`-type `ImportMapping` rows, not just
|
||||||
|
`'product'`/`'variant'`.
|
||||||
|
- `ShopifyExportImporter::resolveOrImportImage()` trusted a cached `ImportMapping`'d `Media`
|
||||||
|
object unconditionally — now verifies the row still exists and is still attached to the
|
||||||
|
current product before reusing it, falling through to a fresh import/attach otherwise. Makes
|
||||||
|
a re-import robust to orphaned media regardless of what left them behind (e.g. a prior
|
||||||
|
`WipeCatalogCommand` run, before the fix above).
|
||||||
|
- Cart admin view (`Cart\Filament\Resources\CartResource\Pages\ViewCart`) 500'd
|
||||||
|
(`Lunar\Exceptions\MissingCurrencyPriceException`) for any cart still holding a line whose
|
||||||
|
purchasable no longer exists (e.g. after `boboko:wipe-catalog`) — `Cart::calculate()` now has
|
||||||
|
that exception caught, falling back to an uncalculated cart; every total field already
|
||||||
|
rendered `?->formatted() ?? '—'`, so the page degrades to showing "—" instead of a 500.
|
||||||
|
- Creating or editing a Payment Method offered "Capture mode" (Charge immediately / Hold now,
|
||||||
|
charge later) even for `cash-on-delivery`, whose driver has no `authorize()` method at
|
||||||
|
all — selecting "authorize" there would have fatally errored at checkout. Now hidden/
|
||||||
|
non-required unless the resolved driver implements `SupportsAuthorization`.
|
||||||
|
- `Lunar\Base\Traits\Searchable::indexer()` (and its sibling filterable/sortable-attribute
|
||||||
|
methods) resolve their configured indexer via `$config[self::class]` — but `self::class`
|
||||||
|
inside a trait method is a compile-time literal bound to whichever class first `use`s the
|
||||||
|
trait, so it always evaluates to `Lunar\Models\Product`, never a subclass, regardless of
|
||||||
|
which instance calls it. `config/lunar/search.php`'s `'indexers'` map must stay keyed by
|
||||||
|
`Lunar\Models\Product::class`, not the new `Product` subclass — keying it by the subclass
|
||||||
|
made the lookup miss entirely and silently fall back to a near-empty default indexer, wiping
|
||||||
|
every filterable/sortable attribute the index had. Caught live, reverted; documented in the
|
||||||
|
config file itself so it isn't repeated.
|
||||||
|
- `CustomerServiceProvider`/`CatalogServiceProvider` called `ModelManifest::replace()` directly
|
||||||
|
from `boot()` — `LunarServiceProvider` (lunarphp/core) calls `Facades\ModelManifest::
|
||||||
|
register()` from its OWN `boot()`, re-discovering every `Lunar\Models\*` class and silently
|
||||||
|
overwriting any `replace()` registered earlier in the provider boot order. Both now defer to
|
||||||
|
`$this->app->booted()`, which only runs once every provider's `boot()` has completed.
|
||||||
|
|
||||||
## [0.19.0] - 2026-09-18
|
## [0.19.0] - 2026-09-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -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.19.0",
|
"version": "0.20.0",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-product, customer-authored input fields — a personalized-statue
|
||||||
|
* product needing a reference photo upload and an optional engraving
|
||||||
|
* textarea, for example. Deliberately NOT modeled as a Lunar ProductOption
|
||||||
|
* (see Modules\Core\Catalog\Contracts\ProductOptionTypeInterface's own
|
||||||
|
* docblock): an option's values are a fixed, admin-authored list that
|
||||||
|
* define variants (Red/Green/Blue) — a photo upload has no such list, it's
|
||||||
|
* unique per order, and creates no variant at all. This is a genuinely
|
||||||
|
* different concept that happens to configure on the same product page.
|
||||||
|
*
|
||||||
|
* Array of {key, type: 'text'|'textarea'|'file', label, required} — `key`
|
||||||
|
* is what a submitted answer is keyed by in CartLine/OrderLine.meta (both
|
||||||
|
* already have a `meta` json column — see Modules\Core\Cart\Services\
|
||||||
|
* CartService::addLine()'s own $meta parameter), not a new table, since
|
||||||
|
* this is small, rarely-queried per-product config, the same reasoning
|
||||||
|
* ShippingMethod.data/PaymentMethod.data already follow for their own
|
||||||
|
* per-row settings.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
|
||||||
|
$table->json('custom_fields')->nullable()->after('attribute_data');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('custom_fields');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<p>Hi {{ $name }},</p>
|
<p>Hi {{ $name }},</p>
|
||||||
|
|
||||||
<p>Your login code is:</p>
|
<p>{{ $intro }}</p>
|
||||||
|
|
||||||
<p style="font-size: 2rem; font-weight: bold; letter-spacing: 0.25rem;">{{ $code }}</p>
|
<p style="font-size: 2rem; font-weight: bold; letter-spacing: 0.25rem;">{{ $code }}</p>
|
||||||
@@ -6,20 +6,47 @@ use Illuminate\Mail\Mailable;
|
|||||||
use Illuminate\Mail\Mailables\Content;
|
use Illuminate\Mail\Mailables\Content;
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one OTP email template for every use of Auth\Services\OtpService —
|
||||||
|
* not just admin login. A code confirming a destructive Artisan command
|
||||||
|
* (e.g. Command\WipeCatalogCommand) reuses the exact same generation/
|
||||||
|
* validation mechanism as login, but "Your login code" as the subject
|
||||||
|
* would be actively misleading for that — the recipient never initiated a
|
||||||
|
* login. $purpose is a small, fixed set of known keys (see
|
||||||
|
* COPY_BY_PURPOSE), not free text — a typo'd/unknown purpose falls back
|
||||||
|
* to 'login' rather than rendering a blank subject/intro.
|
||||||
|
*/
|
||||||
class OtpMail extends Mailable
|
class OtpMail extends Mailable
|
||||||
{
|
{
|
||||||
|
private const COPY_BY_PURPOSE = [
|
||||||
|
'login' => [
|
||||||
|
'subject' => 'Your login code',
|
||||||
|
'intro' => 'Your login code is:',
|
||||||
|
],
|
||||||
|
'wipe-catalog' => [
|
||||||
|
'subject' => 'Confirm: Wipe Catalog',
|
||||||
|
'intro' => 'Someone requested to permanently delete every product in the catalog. If this was you, enter this code to confirm:',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $name,
|
public readonly string $name,
|
||||||
public readonly string $code,
|
public readonly string $code,
|
||||||
|
public readonly string $purpose = 'login',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(subject: 'Your login code');
|
return new Envelope(subject: $this->copy()['subject']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function content(): Content
|
public function content(): Content
|
||||||
{
|
{
|
||||||
return new Content(view: 'core::auth.mail.otp');
|
return new Content(view: 'core::auth.mail.otp', with: ['intro' => $this->copy()['intro']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function copy(): array
|
||||||
|
{
|
||||||
|
return self::COPY_BY_PURPOSE[$this->purpose] ?? self::COPY_BY_PURPOSE['login'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ class OtpService
|
|||||||
private const EXPIRY_MINUTES = 10;
|
private const EXPIRY_MINUTES = 10;
|
||||||
private const CODE_LENGTH = 6;
|
private const CODE_LENGTH = 6;
|
||||||
|
|
||||||
public function generateAndSend(string $email): bool
|
/**
|
||||||
|
* $purpose is forwarded as-is to OtpMail, which only recognizes a
|
||||||
|
* fixed set of keys (see its own COPY_BY_PURPOSE) — an unrecognized
|
||||||
|
* value there just falls back to 'login' rather than failing here, so
|
||||||
|
* this method has nothing of its own to validate.
|
||||||
|
*/
|
||||||
|
public function generateAndSend(string $email, string $purpose = 'login'): bool
|
||||||
{
|
{
|
||||||
$staff = Staff::where('email', $email)->first();
|
$staff = Staff::where('email', $email)->first();
|
||||||
|
|
||||||
@@ -25,7 +31,7 @@ class OtpService
|
|||||||
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
|
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
|
||||||
$staff->save();
|
$staff->save();
|
||||||
|
|
||||||
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code));
|
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code, $purpose));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Lunar\Admin\Filament\Resources\CustomerResource;
|
use Lunar\Admin\Filament\Resources\CustomerResource;
|
||||||
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
|
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
|
||||||
|
use Lunar\Exceptions\MissingCurrencyPriceException;
|
||||||
use Lunar\Models\Cart;
|
use Lunar\Models\Cart;
|
||||||
use Lunar\Models\CartLine;
|
use Lunar\Models\CartLine;
|
||||||
use Lunar\Models\ProductVariant;
|
use Lunar\Models\ProductVariant;
|
||||||
@@ -47,6 +48,17 @@ class ViewCart extends ViewRecord
|
|||||||
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
|
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
|
||||||
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
|
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
|
||||||
* rendering the product grid doesn't N+1 per line.
|
* rendering the product grid doesn't N+1 per line.
|
||||||
|
*
|
||||||
|
* calculate() throws Lunar\Exceptions\MissingCurrencyPriceException
|
||||||
|
* (vendor PricingManager) the moment ANY line's purchasable has no
|
||||||
|
* price row for the cart's currency — including a line whose
|
||||||
|
* purchasable no longer exists at all (a deleted ProductVariant still
|
||||||
|
* referenced by cart_lines.purchasable_id), which 500'd this whole
|
||||||
|
* page rather than just leaving that one line unpriced. The Lines
|
||||||
|
* section below already guards every purchasable-derived field with
|
||||||
|
* `instanceof ProductVariant` and renders fine with $cart left
|
||||||
|
* uncalculated — subTotal/total/etc. simply won't be populated, which
|
||||||
|
* reads as a stale/pending state rather than a broken page.
|
||||||
*/
|
*/
|
||||||
protected function resolveRecord(int|string $key): Cart
|
protected function resolveRecord(int|string $key): Cart
|
||||||
{
|
{
|
||||||
@@ -58,7 +70,11 @@ class ViewCart extends ViewRecord
|
|||||||
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
|
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
|
||||||
->loadMissing(['product.thumbnail', 'images', 'values']);
|
->loadMissing(['product.thumbnail', 'images', 'values']);
|
||||||
|
|
||||||
|
try {
|
||||||
return $cart->calculate();
|
return $cart->calculate();
|
||||||
|
} catch (MissingCurrencyPriceException) {
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function infolist(Schema $schema): Schema
|
public function infolist(Schema $schema): Schema
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ enum ProductSort: string
|
|||||||
case PriceAsc = 'price_asc';
|
case PriceAsc = 'price_asc';
|
||||||
case PriceDesc = 'price_desc';
|
case PriceDesc = 'price_desc';
|
||||||
case Newest = 'newest';
|
case Newest = 'newest';
|
||||||
|
case Popularity = 'popularity';
|
||||||
|
|
||||||
public function toMeilisearchSort(): string
|
public function toMeilisearchSort(): string
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,12 @@ enum ProductSort: string
|
|||||||
self::PriceAsc => 'price:asc',
|
self::PriceAsc => 'price:asc',
|
||||||
self::PriceDesc => 'price:desc',
|
self::PriceDesc => 'price:desc',
|
||||||
self::Newest => 'created_at:desc',
|
self::Newest => 'created_at:desc',
|
||||||
|
// order_count — see Modules\Core\Catalog\Services\
|
||||||
|
// ProductIndexer::toSearchableArray()'s own docblock: the same
|
||||||
|
// trailing-year, physical-order-line-count definition Lunar's
|
||||||
|
// own admin dashboard "Popular Products" widget already uses,
|
||||||
|
// aggregated per product rather than per variant.
|
||||||
|
self::Popularity => 'order_count:desc',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Catalog\Models;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered via Lunar\Facades\ModelManifest::replace(Lunar\Models\
|
||||||
|
* Product::class, self::class) — see Providers\CatalogServiceProvider —
|
||||||
|
* purely to add a cast AND fillable entry for `custom_fields` (see the
|
||||||
|
* migration adding that column: database/migrations/
|
||||||
|
* ..._add_custom_fields_to_products_table.php). Without the fillable
|
||||||
|
* entry, Lunar\Models\Product's own $fillable allowlist (attribute_data,
|
||||||
|
* product_type_id, status, brand_id — custom_fields isn't in it) silently
|
||||||
|
* drops the field on every mass-assignment save (Filament's own
|
||||||
|
* $record->update($data)) — no error, no exception, the admin form shows
|
||||||
|
* the repeater's rows as saved right up until the next page load, when
|
||||||
|
* they're simply gone. Caught in practice.
|
||||||
|
*
|
||||||
|
* ModelManifest::replace() only changes what code resolving Product
|
||||||
|
* through the CONTRACT (app(Contracts\Product::class), Filament's own
|
||||||
|
* ProductResource — its $model is ProductContract::class, not the
|
||||||
|
* concrete class) or the morph map receives — it does NOT retroactively
|
||||||
|
* change what a hardcoded `Lunar\Models\Product::query()`/`::find()`
|
||||||
|
* elsewhere in this codebase (or Lunar's own internals, e.g. the
|
||||||
|
* scheduled Meilisearch reindex command — see CatalogServiceProvider,
|
||||||
|
* which references this subclass by name specifically so that path picks
|
||||||
|
* it up too) resolves to. Most of this codebase's existing Product
|
||||||
|
* references are plain type-hints (they accept whichever instance is
|
||||||
|
* handed to them, subclass included) or don't touch `custom_fields` at
|
||||||
|
* all, so they're unaffected either way.
|
||||||
|
*/
|
||||||
|
class Product extends \Lunar\Models\Product
|
||||||
|
{
|
||||||
|
// NOT `protected $casts = [...]` — that property assignment REPLACES
|
||||||
|
// the parent's own $casts array wholesale rather than merging with
|
||||||
|
// it (PHP class property redeclaration has no merge semantics), which
|
||||||
|
// would silently drop every cast Lunar\Models\Product already
|
||||||
|
// defines (attribute_data, status, etc.). mergeCasts() is Eloquent's
|
||||||
|
// own documented mechanism for a subclass adding to, not replacing,
|
||||||
|
// its parent's casts.
|
||||||
|
public function __construct(array $attributes = [])
|
||||||
|
{
|
||||||
|
parent::__construct($attributes);
|
||||||
|
|
||||||
|
$this->mergeCasts([
|
||||||
|
'custom_fields' => 'array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mergeFillable([
|
||||||
|
'custom_fields',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace Modules\Core\Catalog\Services;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Lunar\Models\Currency;
|
use Lunar\Models\Currency;
|
||||||
|
use Lunar\Models\OrderLine;
|
||||||
use Lunar\Models\Price;
|
use Lunar\Models\Price;
|
||||||
use Lunar\Models\Product;
|
use Lunar\Models\Product;
|
||||||
use Lunar\Models\ProductVariant;
|
use Lunar\Models\ProductVariant;
|
||||||
@@ -103,6 +104,7 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
return [
|
return [
|
||||||
...parent::getSortableFields(),
|
...parent::getSortableFields(),
|
||||||
'price',
|
'price',
|
||||||
|
'order_count',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +141,12 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
->all();
|
->all();
|
||||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||||
$data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all();
|
$data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all();
|
||||||
|
// Only decoded correctly when $model is an instance of
|
||||||
|
// Modules\Core\Catalog\Models\Product (the custom_fields cast
|
||||||
|
// lives there, not on the base Lunar\Models\Product) — see
|
||||||
|
// CatalogServiceProvider's own comment on why the scheduled
|
||||||
|
// reindex command references that subclass by name specifically.
|
||||||
|
$data['custom_fields'] = $model->custom_fields ?? [];
|
||||||
$data['tags'] = $model->tags->pluck('value')->all();
|
$data['tags'] = $model->tags->pluck('value')->all();
|
||||||
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
|
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
|
||||||
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
|
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
|
||||||
@@ -155,6 +163,24 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
$data['in_stock'] = $model->variants->contains(
|
$data['in_stock'] = $model->variants->contains(
|
||||||
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
|
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
|
||||||
);
|
);
|
||||||
|
// Same "popular" definition as Lunar's own admin dashboard widget
|
||||||
|
// (Lunar\Admin\Filament\Widgets\Dashboard\Orders\
|
||||||
|
// PopularProductsTable) — order-line COUNT, not summed quantity,
|
||||||
|
// over the trailing year, physical lines only — just aggregated
|
||||||
|
// per PRODUCT here (across all its variants) rather than per
|
||||||
|
// variant/identifier, since a storefront "sort by popularity"
|
||||||
|
// ranks products, not individual variant SKUs. Necessarily as
|
||||||
|
// stale as any other reindex-time field here (in_stock, price) —
|
||||||
|
// there's no live equivalent without a query per page load.
|
||||||
|
$data['order_count'] = OrderLine::query()
|
||||||
|
->whereIn('purchasable_id', $model->variants->pluck('id'))
|
||||||
|
->where('purchasable_type', 'product_variant')
|
||||||
|
->where('type', 'physical')
|
||||||
|
->whereHas('order', fn ($query) => $query->whereBetween('placed_at', [
|
||||||
|
now()->subYear()->startOfDay(),
|
||||||
|
now()->endOfDay(),
|
||||||
|
]))
|
||||||
|
->count();
|
||||||
$data['recommendations'] = app(RecommendationService::class)
|
$data['recommendations'] = app(RecommendationService::class)
|
||||||
->recommend($model)
|
->recommend($model)
|
||||||
->load(['media', 'variants.prices'])
|
->load(['media', 'variants.prices'])
|
||||||
|
|||||||
@@ -254,7 +254,10 @@ class ProductService
|
|||||||
public function random(int $limit): array
|
public function random(int $limit): array
|
||||||
{
|
{
|
||||||
$raw = Product::search('')
|
$raw = Product::search('')
|
||||||
->options(['attributesToRetrieve' => ['id']])
|
->options([
|
||||||
|
'attributesToRetrieve' => ['id'],
|
||||||
|
'filter' => $this->filterBuilder->withVisibility(),
|
||||||
|
])
|
||||||
->raw();
|
->raw();
|
||||||
|
|
||||||
$ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
|
$ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
|
||||||
@@ -287,7 +290,7 @@ class ProductService
|
|||||||
private function findAllWhere(string $filter, int $limit = 1000): array
|
private function findAllWhere(string $filter, int $limit = 1000): array
|
||||||
{
|
{
|
||||||
$paginator = Product::search('')
|
$paginator = Product::search('')
|
||||||
->options(['filter' => $filter])
|
->options(['filter' => $this->filterBuilder->withVisibility($filter)])
|
||||||
->paginateRaw(perPage: $limit, page: 1);
|
->paginateRaw(perPage: $limit, page: 1);
|
||||||
|
|
||||||
return collect($this->localizer->hitsFrom($paginator))
|
return collect($this->localizer->hitsFrom($paginator))
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Catalog\Services;
|
||||||
|
|
||||||
|
use Lunar\Models\ProductVariant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a SKU for every ProductVariant missing one — extracted out of
|
||||||
|
* Command\BackfillMissingSkusCommand (which becomes a thin CLI wrapper
|
||||||
|
* around this, keeping --dry-run/progress-bar concerns out of the
|
||||||
|
* reusable logic) so MigrateImport\RunMigrateImportJob can also call it
|
||||||
|
* directly, right after a Shopify import, with no CLI concerns at all.
|
||||||
|
*
|
||||||
|
* Format is "SKU-P{product_id}-V{variant_id}": deterministic and
|
||||||
|
* guaranteed unique without a uniqueness check, since product_id/
|
||||||
|
* variant_id already are. Only variants with a null `sku` are touched —
|
||||||
|
* not an importer bug when one shows up after a Shopify import, the
|
||||||
|
* source CSV rows genuinely had no `Variant SKU` value (see
|
||||||
|
* MigrateImport\Shopify\ShopifyExportImporter).
|
||||||
|
*/
|
||||||
|
class SkuBackfillService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ?callable(ProductVariant, string): void $onEach invoked
|
||||||
|
* once per variant with the sku about to be written (or, when
|
||||||
|
* $dryRun is true, that WOULD be written) — the command's own
|
||||||
|
* --dry-run listing and progress bar hook in here without this
|
||||||
|
* service knowing anything about console output.
|
||||||
|
* @return int the number of variants processed
|
||||||
|
*/
|
||||||
|
public function backfill(bool $dryRun = false, ?callable $onEach = null): int
|
||||||
|
{
|
||||||
|
$query = ProductVariant::query()->whereNull('sku');
|
||||||
|
$total = $query->count();
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->chunkById(500, function ($variants) use ($dryRun, $onEach) {
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$variant->update(['sku' => $sku]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($onEach !== null) {
|
||||||
|
$onEach($variant, $sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,13 @@ use Modules\Core\Catalog\DTOs\ProductFilters;
|
|||||||
* out of ProductService (where it originated, scoped to browsing/filtering
|
* out of ProductService (where it originated, scoped to browsing/filtering
|
||||||
* without a search term) so ProductSearchService can apply the exact same
|
* without a search term) so ProductSearchService can apply the exact same
|
||||||
* filter semantics to a text query too, rather than reimplementing it.
|
* filter semantics to a text query too, rather than reimplementing it.
|
||||||
|
*
|
||||||
|
* Also the single place that composes the draft-visibility clause (see
|
||||||
|
* withVisibility()) — every Meilisearch `filter` string ProductService
|
||||||
|
* constructs, including the handful of ad-hoc ones that don't call build()
|
||||||
|
* at all (getById()/getBySlug()'s id lookup, random()'s id-only fetch),
|
||||||
|
* goes through this class so none of them can silently omit it the way a
|
||||||
|
* status filter was missing everywhere until now.
|
||||||
*/
|
*/
|
||||||
class ProductFilterBuilder
|
class ProductFilterBuilder
|
||||||
{
|
{
|
||||||
@@ -19,10 +26,10 @@ class ProductFilterBuilder
|
|||||||
* ProductService::priceRange() excludes 'price' so a price slider's own
|
* ProductService::priceRange() excludes 'price' so a price slider's own
|
||||||
* bounds don't shrink to whatever range is already selected on it.
|
* bounds don't shrink to whatever range is already selected on it.
|
||||||
*/
|
*/
|
||||||
public function build(?ProductFilters $filters, array $exclude = []): ?string
|
public function build(?ProductFilters $filters, array $exclude = []): string
|
||||||
{
|
{
|
||||||
if ($filters === null) {
|
if ($filters === null) {
|
||||||
return null;
|
return $this->withVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
$clauses = Collection::make([
|
$clauses = Collection::make([
|
||||||
@@ -36,6 +43,27 @@ class ProductFilterBuilder
|
|||||||
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
|
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
|
||||||
])->except($exclude)->filter();
|
])->except($exclude)->filter();
|
||||||
|
|
||||||
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
|
return $this->withVisibility($clauses->isEmpty() ? null : $clauses->join(' AND '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draft product (status = 'draft', see Lunar\Filament\Resources\
|
||||||
|
* ProductResource's own status Select) is only ever visible while
|
||||||
|
* APP_DEBUG is true — a merchant/developer previewing an unfinished
|
||||||
|
* product locally or on a staging box, never a real storefront
|
||||||
|
* visitor. Every ProductService method that builds a Meilisearch
|
||||||
|
* `filter` string, build() included, calls this rather than passing
|
||||||
|
* $rawClause straight to Product::search() — the one seam that
|
||||||
|
* guarantees none of them can omit the visibility rule.
|
||||||
|
*
|
||||||
|
* Always returns a non-empty string (never null) — a bare
|
||||||
|
* 'status = "published"' is itself a complete, valid Meilisearch
|
||||||
|
* filter on its own when $rawClause is null.
|
||||||
|
*/
|
||||||
|
public function withVisibility(?string $rawClause = null): string
|
||||||
|
{
|
||||||
|
$visibility = config('app.debug') ? null : 'status = "published"';
|
||||||
|
|
||||||
|
return Collection::make([$visibility, $rawClause])->filter()->join(' AND ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ namespace Modules\Core\Command;
|
|||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Lunar\Models\ProductVariant;
|
use Lunar\Models\ProductVariant;
|
||||||
|
use Modules\Core\Catalog\Services\SkuBackfillService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-off backfill for variants the Shopify import left with a blank SKU —
|
* CLI wrapper (--dry-run, a progress bar) around Catalog\Services\
|
||||||
* not an importer bug, the source CSV rows genuinely had no `Variant SKU`
|
* SkuBackfillService — see that class's own docblock for the actual
|
||||||
* value (see Modules\MigrateImport\Shopify\ShopifyExportImporter) — so
|
* backfill logic, also called automatically after a Shopify import (see
|
||||||
* this synthesizes one instead of re-running the import. Format is
|
* MigrateImport\RunMigrateImportJob).
|
||||||
* "SKU-P{product_id}-V{variant_id}": deterministic and guaranteed unique
|
|
||||||
* without a uniqueness check, since product_id/variant_id already are.
|
|
||||||
* Only variants with a null `sku` are touched.
|
|
||||||
*/
|
*/
|
||||||
class BackfillMissingSkusCommand extends Command
|
class BackfillMissingSkusCommand extends Command
|
||||||
{
|
{
|
||||||
@@ -20,12 +18,11 @@ class BackfillMissingSkusCommand extends Command
|
|||||||
|
|
||||||
protected $description = 'Generate a SKU for every product variant that is missing one';
|
protected $description = 'Generate a SKU for every product variant that is missing one';
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(SkuBackfillService $backfill): void
|
||||||
{
|
{
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
$query = ProductVariant::query()->whereNull('sku');
|
$total = ProductVariant::query()->whereNull('sku')->count();
|
||||||
$total = $query->count();
|
|
||||||
|
|
||||||
if ($total === 0) {
|
if ($total === 0) {
|
||||||
$this->info('No variants are missing a SKU.');
|
$this->info('No variants are missing a SKU.');
|
||||||
@@ -38,19 +35,13 @@ class BackfillMissingSkusCommand extends Command
|
|||||||
$bar = $this->output->createProgressBar($total);
|
$bar = $this->output->createProgressBar($total);
|
||||||
$bar->start();
|
$bar->start();
|
||||||
|
|
||||||
$query->chunkById(500, function ($variants) use ($dryRun, $bar) {
|
$backfill->backfill($dryRun, function (ProductVariant $variant, string $sku) use ($dryRun, $bar) {
|
||||||
foreach ($variants as $variant) {
|
|
||||||
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
|
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
$this->line("Variant {$variant->id}: sku => {$sku}");
|
$this->line("Variant {$variant->id}: sku => {$sku}");
|
||||||
} else {
|
|
||||||
$variant->update(['sku' => $sku]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$bar->advance();
|
$bar->advance();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$bar->finish();
|
$bar->finish();
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Command;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
use Modules\Core\Auth\Models\Staff;
|
||||||
|
use Modules\Core\Auth\Services\OtpService;
|
||||||
|
use Modules\Core\MigrateImport\Models\ImportMapping;
|
||||||
|
|
||||||
|
use function Laravel\Prompts\password;
|
||||||
|
use function Laravel\Prompts\text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Irreversibly deletes every Product and everything that only exists
|
||||||
|
* because of a product — variants, variant prices, product-option value
|
||||||
|
* assignments, product images/media, product associations, the
|
||||||
|
* ImportMapping rows tying them back to an external source, and the
|
||||||
|
* Meilisearch product index. Deliberately does NOT touch catalog
|
||||||
|
* STRUCTURE other products could still reference: ProductOption/
|
||||||
|
* ProductOptionValue definitions ("Size", "Color" as reusable option
|
||||||
|
* types), Brands, Collections, Tags, Customer Groups — none of those are
|
||||||
|
* products, they're config a merchant would otherwise have to rebuild
|
||||||
|
* from scratch.
|
||||||
|
*
|
||||||
|
* Two gates a destructive, whole-catalog, irreversible operation
|
||||||
|
* warrants — deliberately NOT restricted to non-production on top of
|
||||||
|
* these; a real, legitimate use case is wiping a client's demo/seed
|
||||||
|
* catalog on a production database right before real launch, and the OTP
|
||||||
|
* below already proves the operator has real staff access, not just
|
||||||
|
* shell access to wherever `php artisan` happens to be runnable:
|
||||||
|
* 1. An OTP emailed to a real Staff account (reusing Auth\Services\
|
||||||
|
* OtpService — the exact mechanism admin login already uses).
|
||||||
|
* 2. Typing the literal product count back, not just "yes" — a plain
|
||||||
|
* confirm() is too easy to reflexively accept; forcing the operator
|
||||||
|
* to read and retype the actual number they're about to delete is a
|
||||||
|
* last check against running this against the wrong environment/
|
||||||
|
* database by mistake.
|
||||||
|
*
|
||||||
|
* Deletes via Eloquent model instances, not DB::table()->delete() —
|
||||||
|
* Product/ProductVariant use Spatie's InteractsWithMedia (see Lunar\Base\
|
||||||
|
* Traits\HasMedia), which only cleans up media files/rows on a real model
|
||||||
|
* `deleted` event, never on a raw query-builder delete.
|
||||||
|
*/
|
||||||
|
class WipeCatalogCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'boboko:wipe-catalog {--email= : Staff email to send the confirmation code to}';
|
||||||
|
|
||||||
|
protected $description = 'Irreversibly delete every product, variant, and related catalog data';
|
||||||
|
|
||||||
|
public function handle(OtpService $otp): int
|
||||||
|
{
|
||||||
|
// withTrashed() — a prior soft-delete-only bug in this command
|
||||||
|
// (fixed in wipe() below) could leave ghost rows a plain count()
|
||||||
|
// would never see, silently reporting "nothing to do" while they
|
||||||
|
// sit there breaking other things (e.g. the admin's own global
|
||||||
|
// search, which assumes every returned product has variants).
|
||||||
|
$productCount = Product::withTrashed()->count();
|
||||||
|
|
||||||
|
if ($productCount === 0) {
|
||||||
|
$this->info('No products exist — nothing to do.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->authorize($otp)) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn("This will PERMANENTLY delete {$productCount} product(s) and everything that only exists because of them (variants, prices, images, product-option assignments, associations). This cannot be undone.");
|
||||||
|
|
||||||
|
$typed = text(label: "Type the product count ({$productCount}) to confirm");
|
||||||
|
|
||||||
|
if ($typed !== (string) $productCount) {
|
||||||
|
$this->error('Count did not match — aborted, nothing was deleted.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->wipe();
|
||||||
|
|
||||||
|
$this->info("Deleted {$productCount} product(s) and all related data.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorize(OtpService $otp): bool
|
||||||
|
{
|
||||||
|
$email = $this->option('email') ?? text(
|
||||||
|
label: 'Staff email to send a confirmation code to',
|
||||||
|
validate: fn (string $value) => Staff::where('email', $value)->exists()
|
||||||
|
? null
|
||||||
|
: 'No staff account with that email exists.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $otp->generateAndSend($email, purpose: 'wipe-catalog')) {
|
||||||
|
$this->error('Could not send a confirmation code to that email.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("A confirmation code was sent to {$email}.");
|
||||||
|
|
||||||
|
$code = password(label: 'Enter the confirmation code');
|
||||||
|
|
||||||
|
if ($otp->validate($email, $code) === null) {
|
||||||
|
$this->error('Invalid or expired code — aborted, nothing was deleted.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every step below goes through a real Eloquent relation, never a raw
|
||||||
|
* table name — Lunar's own table prefix is configurable
|
||||||
|
* (config('lunar.database.table_prefix'), applied in BaseModel's
|
||||||
|
* constructor), so a hardcoded 'lunar_...' string would silently
|
||||||
|
* no-op on an install using a different one.
|
||||||
|
*
|
||||||
|
* Order matters: product_associations and the product/product_option
|
||||||
|
* pivot have a real FK to `products` but no ON DELETE CASCADE (both
|
||||||
|
* RESTRICT, Laravel's own default), so they're detached before the
|
||||||
|
* product/variant rows they reference — deleting a product that
|
||||||
|
* still has either would throw. ProductVariant's own `prices` (a
|
||||||
|
* plain morph, HasPrices trait — no FK constraint at all) would
|
||||||
|
* otherwise silently orphan rather than throw, so it's cleared the
|
||||||
|
* same way regardless. media_variant and product_option_value_
|
||||||
|
* product_variant DO cascade at the DB level (see their own
|
||||||
|
* migrations), so deleting the variant itself is enough for those two.
|
||||||
|
*
|
||||||
|
* Deliberately NOT chunkById() — that re-queries "id > lastSeenId"
|
||||||
|
* every iteration, but deleting rows inside the loop shrinks the
|
||||||
|
* table out from under it: any product whose id fell in a range
|
||||||
|
* chunkById() had already stepped past could be silently skipped and
|
||||||
|
* never actually deleted at all. Caught in practice — the first real
|
||||||
|
* run of this command left orphaned Media rows (Spatie's own
|
||||||
|
* deleteAllMedia(), fired from Product's `deleting` event, never ran
|
||||||
|
* for the skipped products) whose 'image' ImportMapping rows then
|
||||||
|
* caused a LATER Shopify re-import to silently reuse those now-
|
||||||
|
* orphaned Media objects instead of importing fresh ones — see
|
||||||
|
* MigrateImport\Shopify\ShopifyExportImporter::resolveOrImportImage()'s
|
||||||
|
* own docblock for that half of the same incident. Always re-querying
|
||||||
|
* the first N remaining rows (never advancing an id cursor) guarantees
|
||||||
|
* every product is actually visited exactly once, however many are
|
||||||
|
* deleted out from under the query as it goes.
|
||||||
|
*/
|
||||||
|
private function wipe(): void
|
||||||
|
{
|
||||||
|
ImportMapping::whereIn('source_type', ['product', 'variant', 'image'])->delete();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// withTrashed(): Product/ProductVariant both use SoftDeletes
|
||||||
|
// — a plain query would stop seeing a product the moment
|
||||||
|
// forceDelete() below actually removes it, which is fine, but
|
||||||
|
// WITHOUT withTrashed() here this loop would never even
|
||||||
|
// fetch a row that a previous, buggy run of this command
|
||||||
|
// (or any other code) had already soft-deleted without
|
||||||
|
// force-deleting it. Ghost rows like that are exactly what
|
||||||
|
// this command exists to remove.
|
||||||
|
$products = Product::withTrashed()
|
||||||
|
->with(['variants' => fn ($query) => $query->withTrashed(), 'associations', 'inverseAssociations'])
|
||||||
|
->limit(100)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($products->isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$product->associations()->delete();
|
||||||
|
$product->inverseAssociations()->delete();
|
||||||
|
$product->productOptions()->detach();
|
||||||
|
|
||||||
|
foreach ($product->variants as $variant) {
|
||||||
|
$variant->prices()->delete();
|
||||||
|
// NOT delete() — Product/ProductVariant both use
|
||||||
|
// SoftDeletes, and a plain delete() only sets
|
||||||
|
// deleted_at, leaving the row (and, for Product, its
|
||||||
|
// media) sitting in the table. This command's whole
|
||||||
|
// purpose is an irreversible wipe; a soft-deleted
|
||||||
|
// ghost row is the opposite of that. Caught in
|
||||||
|
// practice — a prior run's plain delete() left 185
|
||||||
|
// ghost Product rows with zero real variants, which
|
||||||
|
// then crashed the admin's own global search
|
||||||
|
// (Lunar\Admin\Filament\Resources\ProductResource::
|
||||||
|
// getGlobalSearchResultDetails() assumes
|
||||||
|
// $record->variants->first() is never null).
|
||||||
|
$variant->forceDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$product->forceDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Product::removeAllFromSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
|||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Modules\Core\Catalog\Services\SkuBackfillService;
|
||||||
|
|
||||||
class RunMigrateImportJob implements ShouldQueue
|
class RunMigrateImportJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -20,9 +21,19 @@ class RunMigrateImportJob implements ShouldQueue
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(SkuBackfillService $skuBackfill): void
|
||||||
{
|
{
|
||||||
$importer = ImporterFactory::make($this->spec);
|
$importer = ImporterFactory::make($this->spec);
|
||||||
$importer->import($this->spec);
|
$importer->import($this->spec);
|
||||||
|
|
||||||
|
// Only Shopify's importer creates ProductVariant rows at all (see
|
||||||
|
// Shopify\ShopifyExportImporter) — JudgeMe never touches products,
|
||||||
|
// so running this for that source would just be a guaranteed
|
||||||
|
// no-op query every time. Source CSV rows genuinely can have no
|
||||||
|
// `Variant SKU` value; see SkuBackfillService's own docblock for
|
||||||
|
// why that's synthesized rather than treated as an importer bug.
|
||||||
|
if ($this->spec->source === 'shopify') {
|
||||||
|
$skuBackfill->backfill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ class ShopifyExportImporter implements Importer
|
|||||||
$variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null;
|
$variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null;
|
||||||
$variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0);
|
$variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0);
|
||||||
$variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN);
|
$variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN);
|
||||||
|
// Lunar's own column default is 'always' (purchasable regardless of
|
||||||
|
// stock) — wrong for an imported catalogue, whose Variant Inventory
|
||||||
|
// Qty is real, meaningful stock data. 'in_stock' makes purchasability
|
||||||
|
// actually respect it (see Modules\Core\Catalog\Services\
|
||||||
|
// StockService's own docblock on the three purchasable values).
|
||||||
|
$variant->purchasable = 'in_stock';
|
||||||
$variant->save();
|
$variant->save();
|
||||||
|
|
||||||
ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant);
|
ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant);
|
||||||
@@ -239,7 +245,23 @@ class ShopifyExportImporter implements Importer
|
|||||||
|
|
||||||
$existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId);
|
$existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId);
|
||||||
|
|
||||||
if ($existing instanceof Media) {
|
// ImportMapping is a durable record of "we already imported this,"
|
||||||
|
// but the Media row it points at can go stale — e.g. Command\
|
||||||
|
// WipeCatalogCommand deletes every Product (media included, via
|
||||||
|
// Spatie's own model-delete cleanup) without knowing this mapping
|
||||||
|
// exists, since the mapping ISN'T scoped to a single Product to
|
||||||
|
// clean up alongside it. Re-running an import afterward used to
|
||||||
|
// trust the cached Media object unconditionally — it still existed
|
||||||
|
// as a PHP object even though its underlying row (and file) were
|
||||||
|
// long gone, so every re-imported product silently got zero
|
||||||
|
// media, no error, no warning. Falls through to a fresh import
|
||||||
|
// whenever the mapping doesn't resolve to a real, still-attached
|
||||||
|
// Media row.
|
||||||
|
if ($existing instanceof Media
|
||||||
|
&& Media::whereKey($existing->getKey())->exists()
|
||||||
|
&& $existing->model_type === $product->getMorphClass()
|
||||||
|
&& (int) $existing->model_id === $product->id
|
||||||
|
) {
|
||||||
return $existing;
|
return $existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ namespace Modules\Core\Providers;
|
|||||||
use Illuminate\Console\Scheduling\Schedule;
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Lunar\Models\Product;
|
use Lunar\Facades\ModelManifest;
|
||||||
|
use Lunar\Models\Contracts\Product as ProductContract;
|
||||||
|
use Lunar\Models\Product as LunarProduct;
|
||||||
use Lunar\Models\ProductOption;
|
use Lunar\Models\ProductOption;
|
||||||
use Lunar\Models\ProductOptionValue;
|
use Lunar\Models\ProductOptionValue;
|
||||||
use Modules\Core\Catalog\Events\ProductDeleted;
|
use Modules\Core\Catalog\Events\ProductDeleted;
|
||||||
use Modules\Core\Catalog\Events\ProductSaved;
|
use Modules\Core\Catalog\Events\ProductSaved;
|
||||||
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
|
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
|
||||||
|
use Modules\Core\Catalog\Models\Product;
|
||||||
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
|
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
|
||||||
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
|
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
|
||||||
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
|
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
|
||||||
@@ -40,13 +43,55 @@ class CatalogServiceProvider extends ServiceProvider
|
|||||||
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
|
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
|
||||||
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
|
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
|
||||||
|
|
||||||
Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product)));
|
// Registered on BOTH classes — Eloquent model events are keyed by
|
||||||
Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id)));
|
// the literal class ::saved()/::deleted() was called on
|
||||||
|
// (registerModelEvent() uses static::class at registration time),
|
||||||
|
// not by inheritance, so a listener registered only on one class
|
||||||
|
// never fires for an instance of the other. Code resolving Product
|
||||||
|
// through the contract (Filament's own ProductResource, anything
|
||||||
|
// using app(Contracts\Product::class)) gets the subclass once
|
||||||
|
// ModelManifest::replace() below takes effect; code that still
|
||||||
|
// hardcodes `Lunar\Models\Product` directly (e.g. MigrateImport\
|
||||||
|
// Shopify\ShopifyExportImporter — importing has no reason to need
|
||||||
|
// the subclass's own custom_fields cast) keeps creating base-class
|
||||||
|
// instances. Both must dispatch ProductSaved/ProductDeleted, since
|
||||||
|
// ReindexProductsRecommendingProduct listens to those regardless
|
||||||
|
// of which path created/updated the product.
|
||||||
|
$dispatchSaved = fn (LunarProduct $product) => Event::dispatch(new ProductSaved($product));
|
||||||
|
$dispatchDeleted = fn (LunarProduct $product) => Event::dispatch(new ProductDeleted($product->id));
|
||||||
|
|
||||||
|
LunarProduct::saved($dispatchSaved);
|
||||||
|
LunarProduct::deleted($dispatchDeleted);
|
||||||
|
Product::saved($dispatchSaved);
|
||||||
|
Product::deleted($dispatchDeleted);
|
||||||
|
|
||||||
Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
|
Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
|
||||||
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
|
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
|
||||||
|
|
||||||
$this->app->booted(function () {
|
$this->app->booted(function () {
|
||||||
|
// Deferred to booted() to run after every provider (Lunar's
|
||||||
|
// own included) has finished its own boot() — matches
|
||||||
|
// 3dealer's own AppServiceProvider, which registers Customer
|
||||||
|
// the same way for the same reason.
|
||||||
|
//
|
||||||
|
// The first argument MUST be the CONTRACT
|
||||||
|
// (Lunar\Models\Contracts\Product), not the concrete
|
||||||
|
// Lunar\Models\Product — HasModelExtending::modelClass()
|
||||||
|
// (which every Lunar model's __callStatic()/newModelQuery()
|
||||||
|
// consults to decide "is there a registered replacement for
|
||||||
|
// me") looks itself up by
|
||||||
|
// ModelManifest::guessContractClass(static::class), which
|
||||||
|
// resolves to the CONTRACT interface, then does
|
||||||
|
// ModelManifest::get($thatContract) — so the manifest must be
|
||||||
|
// keyed by the contract, or the lookup simply misses and
|
||||||
|
// silently falls back to the base class. Caught in practice —
|
||||||
|
// passing the concrete LunarProduct::class here (mirroring
|
||||||
|
// Modules\Core\Customer\Providers\CustomerServiceProvider's
|
||||||
|
// own replace() call, which has this exact same bug) left
|
||||||
|
// Product::modelClass() resolving to Lunar\Models\Product no
|
||||||
|
// matter what, until this was corrected.
|
||||||
|
ModelManifest::replace(ProductContract::class, Product::class);
|
||||||
|
|
||||||
// A full nightly reindex, on top of the per-event reindexing
|
// A full nightly reindex, on top of the per-event reindexing
|
||||||
// above — catches everything event-driven reindexing
|
// above — catches everything event-driven reindexing
|
||||||
// deliberately doesn't cover: a newly-created product not yet
|
// deliberately doesn't cover: a newly-created product not yet
|
||||||
@@ -58,8 +103,14 @@ class CatalogServiceProvider extends ServiceProvider
|
|||||||
// documents, so a deploy that changed ProductIndexer's field
|
// documents, so a deploy that changed ProductIndexer's field
|
||||||
// list self-heals here even if `lunar:meilisearch:setup`
|
// list self-heals here even if `lunar:meilisearch:setup`
|
||||||
// wasn't run manually after that deploy.
|
// wasn't run manually after that deploy.
|
||||||
|
// References the subclass, not 'Lunar\Models\Product' — Scout's
|
||||||
|
// own reindex loop (Searchable::makeAllSearchable()) queries
|
||||||
|
// via whatever class name is passed here, so this determines
|
||||||
|
// which class's casts (custom_fields included) are actually
|
||||||
|
// applied to $model in ProductIndexer::toSearchableArray()
|
||||||
|
// during this nightly full reindex.
|
||||||
$this->app->make(Schedule::class)
|
$this->app->make(Schedule::class)
|
||||||
->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh'])
|
->command('lunar:search:index', [Product::class, '--refresh'])
|
||||||
->dailyAt('03:00');
|
->dailyAt('03:00');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Modules\Core\Command\InstallLunarCommand;
|
|||||||
use Modules\Core\Command\MigrateImportCommand;
|
use Modules\Core\Command\MigrateImportCommand;
|
||||||
use Modules\Core\Command\ProcessErasureRequestsCommand;
|
use Modules\Core\Command\ProcessErasureRequestsCommand;
|
||||||
use Modules\Core\Command\TuneProductSearchCommand;
|
use Modules\Core\Command\TuneProductSearchCommand;
|
||||||
|
use Modules\Core\Command\WipeCatalogCommand;
|
||||||
|
|
||||||
class CoreServiceProvider extends ServiceProvider
|
class CoreServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -39,7 +40,7 @@ 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, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class, ProcessErasureRequestsCommand::class]);
|
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class, ProcessErasureRequestsCommand::class, WipeCatalogCommand::class]);
|
||||||
|
|
||||||
//Overriding lunar:install
|
//Overriding lunar:install
|
||||||
$this->app->booted(fn() => $this->commands([InstallLunarCommand::class]));
|
$this->app->booted(fn() => $this->commands([InstallLunarCommand::class]));
|
||||||
|
|||||||
@@ -19,7 +19,20 @@ class CustomerServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
ModelManifest::replace(LunarCustomer::class, Customer::class);
|
// Deferred to booted() — LunarServiceProvider (lunarphp/core)
|
||||||
|
// calls Facades\ModelManifest::register() from its OWN boot(),
|
||||||
|
// which re-discovers every Lunar\Models\* class and repopulates
|
||||||
|
// the whole manifest from scratch, silently undoing a replace()
|
||||||
|
// call made from a boot() that ran earlier in the provider list.
|
||||||
|
// booted() fires only once every provider's boot() has completed,
|
||||||
|
// guaranteeing this is the one that actually sticks — same fix,
|
||||||
|
// same reasoning, as Providers\CatalogServiceProvider's own
|
||||||
|
// Product replace() call. This one likely only "worked" before
|
||||||
|
// because 3dealer's own AppServiceProvider independently
|
||||||
|
// re-registers Customer correctly in its own booted() — a
|
||||||
|
// consuming app without that redundant registration would have
|
||||||
|
// silently gotten the base Lunar\Models\Customer back.
|
||||||
|
$this->app->booted(fn () => ModelManifest::replace(LunarCustomer::class, Customer::class));
|
||||||
|
|
||||||
Event::listen(UserCreated::class, CreateCustomerForUser::class);
|
Event::listen(UserCreated::class, CreateCustomerForUser::class);
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,31 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Review\Filament\Extensions;
|
namespace Modules\Core\Review\Filament\Extensions;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\Repeater;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||||
use Modules\Core\Review\Filament\Pages\ManageProductReviews;
|
use Modules\Core\Review\Filament\Pages\ManageProductReviews;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CorePlugin allows exactly one extension class per Lunar resource — this
|
||||||
|
* one already owned ProductResource (adding the Reviews sub-page) before
|
||||||
|
* Catalog needed its own product-form addition, so extendForm() lives
|
||||||
|
* here too rather than competing for the same resource slot.
|
||||||
|
*
|
||||||
|
* extendForm() adds a "Custom Fields" repeater authoring Product::
|
||||||
|
* $custom_fields (see the migration adding that column, and Modules\Core\
|
||||||
|
* Catalog\Models\Product's own docblock on why this needed a first-party
|
||||||
|
* Product subclass rather than being addable to the base Lunar model) —
|
||||||
|
* per-product, customer-authored input (a reference photo upload, an
|
||||||
|
* optional engraving textarea) rendered on the storefront product page,
|
||||||
|
* NOT a Lunar ProductOption: an option's values are a fixed, admin-
|
||||||
|
* authored list that define variants, which doesn't fit "the customer
|
||||||
|
* uploads their own unique photo."
|
||||||
|
*/
|
||||||
class ProductResourceExtension extends ResourceExtension
|
class ProductResourceExtension extends ResourceExtension
|
||||||
{
|
{
|
||||||
public function extendPages(array $pages): array
|
public function extendPages(array $pages): array
|
||||||
@@ -18,4 +40,56 @@ class ProductResourceExtension extends ResourceExtension
|
|||||||
{
|
{
|
||||||
return [...$pages, ManageProductReviews::class];
|
return [...$pages, ManageProductReviews::class];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function extendForm(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
...$schema->getComponents(),
|
||||||
|
$this->customFieldsSection(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function customFieldsSection(): Section
|
||||||
|
{
|
||||||
|
return Section::make('Custom Fields')
|
||||||
|
->description('Extra input the shopper fills in on this product\'s page before adding it to their cart — a reference photo, personalization text, etc.')
|
||||||
|
->collapsible()
|
||||||
|
->collapsed(fn ($record) => blank($record?->custom_fields))
|
||||||
|
->schema([
|
||||||
|
Repeater::make('custom_fields')
|
||||||
|
->hiddenLabel()
|
||||||
|
->schema([
|
||||||
|
TextInput::make('label')
|
||||||
|
->label('Label')
|
||||||
|
->helperText('Shown to the shopper above the field.')
|
||||||
|
->required(),
|
||||||
|
Select::make('type')
|
||||||
|
->label('Field type')
|
||||||
|
->options([
|
||||||
|
'text' => 'Short text',
|
||||||
|
'textarea' => 'Long text',
|
||||||
|
'file' => 'File upload',
|
||||||
|
])
|
||||||
|
->default('text')
|
||||||
|
->native(false)
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
TextInput::make('key')
|
||||||
|
->label('Key')
|
||||||
|
->helperText('Machine-facing identifier — stored on the order/cart line, used to look up this answer elsewhere. Cannot be changed once orders reference it.')
|
||||||
|
->required()
|
||||||
|
->alphaDash()
|
||||||
|
->maxLength(64),
|
||||||
|
Toggle::make('required')
|
||||||
|
->label('Required')
|
||||||
|
->helperText('Shopper cannot add this product to their cart without answering.')
|
||||||
|
->default(false),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->addActionLabel('Add a custom field')
|
||||||
|
->reorderable()
|
||||||
|
->collapsible()
|
||||||
|
->itemLabel(fn (array $state): ?string => $state['label'] ?? null),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user