Feat: Adding Translations to Payment and Shipping Methods, removing unecessary shipping method fulfillment type
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PaymentMethod.name becomes a locale-keyed JSON array (e.g.
|
||||||
|
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}), rendered in Filament
|
||||||
|
* via Lunar's own Lunar\Admin\Support\Forms\Components\TranslatedText —
|
||||||
|
* the same reusable component/data-shape Product/Collection names already
|
||||||
|
* use (Lunar\Base\Traits\HasTranslations), just applied directly to a
|
||||||
|
* plain column here rather than through attribute_data, since
|
||||||
|
* PaymentMethod is a merchant-configured settings row, not a translatable
|
||||||
|
* catalog attribute.
|
||||||
|
*
|
||||||
|
* Existing plain-string rows are preserved under the store's default
|
||||||
|
* Language code (falls back to 'en' if no Language row exists yet — this
|
||||||
|
* migration can run before lunar:install seeds one) rather than dropped,
|
||||||
|
* so an already-configured payment method's name isn't blanked out.
|
||||||
|
*
|
||||||
|
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
|
||||||
|
* (which requires doctrine/dbal — not installed in this project) —
|
||||||
|
* Postgres-specific (this project runs on `pgsql`, per its own docker
|
||||||
|
* setup), with an explicit USING clause since json isn't implicitly
|
||||||
|
* castable from varchar.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
|
||||||
|
|
||||||
|
$existing = DB::table('payment_methods')->pluck('name', 'id');
|
||||||
|
|
||||||
|
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name DROP DEFAULT');
|
||||||
|
DB::statement("ALTER TABLE payment_methods ALTER COLUMN name TYPE json USING NULL");
|
||||||
|
|
||||||
|
foreach ($existing as $id => $name) {
|
||||||
|
if ($name === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('payment_methods')
|
||||||
|
->where('id', $id)
|
||||||
|
->update(['name' => json_encode([$defaultLocale => $name])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
|
||||||
|
|
||||||
|
$existing = DB::table('payment_methods')->pluck('name', 'id');
|
||||||
|
|
||||||
|
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name TYPE varchar(255) USING NULL');
|
||||||
|
|
||||||
|
foreach ($existing as $id => $name) {
|
||||||
|
$decoded = json_decode((string) $name, true);
|
||||||
|
$flat = is_array($decoded) ? ($decoded[$defaultLocale] ?? reset($decoded) ?: null) : $name;
|
||||||
|
|
||||||
|
DB::table('payment_methods')->where('id', $id)->update(['name' => $flat]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Lunar\Base\Migration;
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShippingMethod.name becomes a locale-keyed JSON array (e.g.
|
||||||
|
* {"en": "Standard Delivery", "el": "Κανονική Παράδοση"}), rendered in
|
||||||
|
* Filament via Lunar's own Lunar\Admin\Support\Forms\Components\
|
||||||
|
* TranslatedText (Modules\Core\Shipping\Extensions\
|
||||||
|
* ShippingMethodResourceExtension::replaceNameField()) — same shape/
|
||||||
|
* resolution as PaymentMethod.name (see its own migration,
|
||||||
|
* 2026_09_15_000001_make_payment_methods_name_translatable.php) and
|
||||||
|
* Product/Collection names (Lunar\Base\Traits\HasTranslations).
|
||||||
|
*
|
||||||
|
* ShippingMethod is a vendor (lunarphp/table-rate-shipping) table, but
|
||||||
|
* converting a vendor column's type via a migration is no different from
|
||||||
|
* any other schema change this project already makes against a vendor
|
||||||
|
* table (see database/migrations/2026_08_31_000001_create_payment_methods_table.php's
|
||||||
|
* sibling migrations for the same pattern against PaymentMethod) — there
|
||||||
|
* was no good reason to route this through `data.name` instead, unlike
|
||||||
|
* `data.fulfillment_type` which is a genuinely NEW field the vendor table
|
||||||
|
* never had at all.
|
||||||
|
*
|
||||||
|
* Existing plain-string rows are preserved under the store's default
|
||||||
|
* Language code (falls back to 'en' if no Language row exists yet)
|
||||||
|
* rather than dropped.
|
||||||
|
*
|
||||||
|
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
|
||||||
|
* (requires doctrine/dbal — not installed in this project) — Postgres-
|
||||||
|
* specific (this project runs on `pgsql`), with an explicit USING clause
|
||||||
|
* since json isn't implicitly castable from varchar.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$table = $this->prefix.'shipping_methods';
|
||||||
|
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
|
||||||
|
|
||||||
|
// The column is NOT NULL (vendor migration never marked it
|
||||||
|
// nullable) — converting via `USING NULL` first, then
|
||||||
|
// backfilling with a second UPDATE, violates that constraint
|
||||||
|
// before the backfill ever runs. json_build_object() converts
|
||||||
|
// each existing string in place, in the same statement, so the
|
||||||
|
// column is never transiently NULL. $defaultLocale is inlined
|
||||||
|
// (not bound) — parameter binding inside an ALTER TABLE ... USING
|
||||||
|
// expression isn't reliable across drivers; it's a Language::code
|
||||||
|
// value we control, not user input, so quote_literal-safe
|
||||||
|
// interpolation here is fine.
|
||||||
|
$quotedLocale = DB::getPdo()->quote($defaultLocale);
|
||||||
|
|
||||||
|
DB::statement("ALTER TABLE {$table} ALTER COLUMN name TYPE json USING json_build_object({$quotedLocale}, name)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$table = $this->prefix.'shipping_methods';
|
||||||
|
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
|
||||||
|
|
||||||
|
// Same NOT NULL constraint applies going back — ->>'{locale}'
|
||||||
|
// extracts the default locale's text value directly in the
|
||||||
|
// USING clause, falling back to the first key present via
|
||||||
|
// COALESCE for any row missing that locale (e.g. one only ever
|
||||||
|
// filled in via a non-default language).
|
||||||
|
$quotedLocale = DB::getPdo()->quote($defaultLocale);
|
||||||
|
|
||||||
|
DB::statement(
|
||||||
|
"ALTER TABLE {$table} ALTER COLUMN name TYPE varchar(255) ".
|
||||||
|
"USING COALESCE(name->>{$quotedLocale}, (SELECT value FROM json_each_text(name) LIMIT 1))"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -66,6 +66,16 @@ class InstallLunarCommand extends Command
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! Language::where('code', 'el')->exists()) {
|
||||||
|
$this->components->info('Adding Greek language');
|
||||||
|
|
||||||
|
Language::create([
|
||||||
|
'code' => 'el',
|
||||||
|
'name' => 'Greek',
|
||||||
|
'default' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if (! Currency::whereDefault(true)->exists()) {
|
if (! Currency::whereDefault(true)->exists()) {
|
||||||
$this->components->info('Adding a default currency (USD)');
|
$this->components->info('Adding a default currency (USD)');
|
||||||
|
|
||||||
@@ -310,7 +320,10 @@ class InstallLunarCommand extends Command
|
|||||||
|
|
||||||
PaymentMethod::create([
|
PaymentMethod::create([
|
||||||
'type' => 'cash-on-delivery',
|
'type' => 'cash-on-delivery',
|
||||||
'name' => 'Cash on Delivery',
|
'name' => [
|
||||||
|
'en' => 'Cash on Delivery',
|
||||||
|
'el' => 'Αντικαταβολή',
|
||||||
|
],
|
||||||
'driver' => 'cash-on-delivery',
|
'driver' => 'cash-on-delivery',
|
||||||
'capture_mode' => 'pay',
|
'capture_mode' => 'pay',
|
||||||
'position' => 0,
|
'position' => 0,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class OrderPaymentMethodSummaryExtension extends ViewPageExtension
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return PaymentMethod::where('type', $type)->value('name') ?? $type;
|
$method = PaymentMethod::where('type', $type)->first();
|
||||||
|
|
||||||
|
return $method?->translate('name') ?? $type;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use Filament\Tables\Columns\TextColumn;
|
|||||||
use Filament\Tables\Columns\ToggleColumn;
|
use Filament\Tables\Columns\ToggleColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Lunar\Admin\Support\Forms\Components\TranslatedText;
|
||||||
use Modules\Core\Payment\Contracts\Configurable;
|
use Modules\Core\Payment\Contracts\Configurable;
|
||||||
use Modules\Core\Payment\Events\PaymentMethodsReordered;
|
use Modules\Core\Payment\Events\PaymentMethodsReordered;
|
||||||
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
|
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
|
||||||
@@ -76,7 +77,7 @@ class PaymentMethodResource extends Resource
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->label('Name')
|
->label('Name')
|
||||||
->searchable(),
|
->state(fn (PaymentMethod $record) => $record->translate('name')),
|
||||||
TextColumn::make('type')
|
TextColumn::make('type')
|
||||||
->label('Type'),
|
->label('Type'),
|
||||||
TextColumn::make('driver')
|
TextColumn::make('driver')
|
||||||
@@ -124,10 +125,9 @@ class PaymentMethodResource extends Resource
|
|||||||
public static function getFormComponents(): array
|
public static function getFormComponents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TextInput::make('name')
|
TranslatedText::make('name')
|
||||||
->label('Name')
|
->label('Name')
|
||||||
->required()
|
->required(),
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('type')
|
TextInput::make('type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
|
->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Modules\Core\Payment\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
|
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Lunar\Base\Traits\HasTranslations;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A merchant-configured payment method — the DB-instance layer, admin
|
* A merchant-configured payment method — the DB-instance layer, admin
|
||||||
@@ -11,7 +12,16 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* shipping_methods table already has (see docs/payments.md):
|
* shipping_methods table already has (see docs/payments.md):
|
||||||
* - type: unique, machine-facing slug (Cart::meta['payment_method'],
|
* - type: unique, machine-facing slug (Cart::meta['payment_method'],
|
||||||
* ApplyPaymentMethodFee's lookup key, every Payment event's $type).
|
* ApplyPaymentMethodFee's lookup key, every Payment event's $type).
|
||||||
* - name: admin-facing label.
|
* - name: admin-facing label, locale-keyed JSON (e.g.
|
||||||
|
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}) — same shape/
|
||||||
|
* resolution as Product/Collection names (Lunar\Base\Traits\
|
||||||
|
* HasTranslations), just applied directly to this column rather than
|
||||||
|
* through attribute_data, since this is a merchant settings row, not
|
||||||
|
* a catalog attribute. Rendered in Filament via Lunar's own
|
||||||
|
* Lunar\Admin\Support\Forms\Components\TranslatedText — one input per
|
||||||
|
* configured Language row, no bespoke translation UI. Resolve a
|
||||||
|
* display string with $method->translate('name') (locale defaults to
|
||||||
|
* app()->getLocale(), falling back to the store's default language).
|
||||||
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
|
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
|
||||||
* — NOT the same as `type`, and not unique (two rows can share one
|
* — NOT the same as `type`, and not unique (two rows can share one
|
||||||
* driver, e.g. two differently-named offline-style methods).
|
* driver, e.g. two differently-named offline-style methods).
|
||||||
@@ -28,12 +38,15 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
*/
|
*/
|
||||||
class PaymentMethod extends Model
|
class PaymentMethod extends Model
|
||||||
{
|
{
|
||||||
|
use HasTranslations;
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'enabled' => 'boolean',
|
'enabled' => 'boolean',
|
||||||
'position' => 'integer',
|
'position' => 'integer',
|
||||||
'driver_missing_at' => 'datetime',
|
'driver_missing_at' => 'datetime',
|
||||||
|
'name' => 'array',
|
||||||
'data' => AsArrayObject::class,
|
'data' => AsArrayObject::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
|||||||
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
||||||
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
|
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
|
||||||
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
|
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
|
||||||
|
use Modules\Core\Shipping\Support\FulfillmentType;
|
||||||
use Modules\Core\Shipping\Models\Shipment;
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
class ShippingServiceProvider extends ServiceProvider
|
class ShippingServiceProvider extends ServiceProvider
|
||||||
@@ -80,8 +81,10 @@ class ShippingServiceProvider extends ServiceProvider
|
|||||||
// resolveCarrier() for the same lookup pattern already used to
|
// resolveCarrier() for the same lookup pattern already used to
|
||||||
// resolve a carrier driver from it).
|
// resolve a carrier driver from it).
|
||||||
//
|
//
|
||||||
// Reads ShippingMethod.data['fulfillment_type'] directly rather
|
// Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver-
|
||||||
// than through a ShippingMethod::macro('isStorePickup', ...) —
|
// declared for acs/box-now, merchant-configured data['fulfillment_type']
|
||||||
|
// fallback for table-rate-shipping's generic drivers) rather than
|
||||||
|
// through a ShippingMethod::macro('isStorePickup', ...) —
|
||||||
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
|
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
|
||||||
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
|
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
|
||||||
// intercepts EVERY unmatched static call, including macro()
|
// intercepts EVERY unmatched static call, including macro()
|
||||||
@@ -91,8 +94,7 @@ class ShippingServiceProvider extends ServiceProvider
|
|||||||
// false. (Lunar\Models\Order is unaffected because it declares
|
// false. (Lunar\Models\Order is unaffected because it declares
|
||||||
// its own macro() method directly, bypassing __callStatic
|
// its own macro() method directly, bypassing __callStatic
|
||||||
// entirely — that's why Order::macro('isStorePickupOrder', ...)
|
// entirely — that's why Order::macro('isStorePickupOrder', ...)
|
||||||
// below still works.) Defaults to 'carrier' (false) for any row
|
// below still works.)
|
||||||
// saved before this field existed.
|
|
||||||
Order::macro('isStorePickupOrder', function () {
|
Order::macro('isStorePickupOrder', function () {
|
||||||
/** @var Order $this */
|
/** @var Order $this */
|
||||||
$code = $this->shippingAddress?->shipping_option;
|
$code = $this->shippingAddress?->shipping_option;
|
||||||
@@ -108,7 +110,7 @@ class ShippingServiceProvider extends ServiceProvider
|
|||||||
// attribute avoids that entirely.
|
// attribute avoids that entirely.
|
||||||
$method = ShippingMethod::where('code', $code)->first();
|
$method = ShippingMethod::where('code', $code)->first();
|
||||||
|
|
||||||
return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup';
|
return $method && FulfillmentType::isStorePickup($method);
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
|
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ use Lunar\Shipping\Models\ShippingRate;
|
|||||||
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
use Modules\Core\Shipping\Concerns\CachesLivePricing;
|
use Modules\Core\Shipping\Concerns\CachesLivePricing;
|
||||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||||
|
use Modules\Core\Shipping\Support\ShippingMethodName;
|
||||||
use Modules\Core\Shipping\Support\WeightCalculator;
|
use Modules\Core\Shipping\Support\WeightCalculator;
|
||||||
|
|
||||||
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType
|
||||||
{
|
{
|
||||||
use ResolvesFixedPricing;
|
use ResolvesFixedPricing;
|
||||||
use CachesLivePricing;
|
use CachesLivePricing;
|
||||||
@@ -30,6 +32,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
|||||||
return 'ACS Courier';
|
return 'ACS Courier';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fulfillmentType(): string
|
||||||
|
{
|
||||||
|
return 'carrier';
|
||||||
|
}
|
||||||
|
|
||||||
public function description(): string
|
public function description(): string
|
||||||
{
|
{
|
||||||
return 'Live rate quote from ACS Courier.';
|
return 'Live rate quote from ACS Courier.';
|
||||||
@@ -84,7 +91,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
|||||||
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
|
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
|
||||||
|
|
||||||
return new ShippingOption(
|
return new ShippingOption(
|
||||||
name: $shippingMethod->name ?: $this->name(),
|
name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
|
||||||
description: $shippingMethod->description ?: $this->description(),
|
description: $shippingMethod->description ?: $this->description(),
|
||||||
identifier: $shippingRate->getIdentifier(),
|
identifier: $shippingRate->getIdentifier(),
|
||||||
price: new Price($amount, $cart->currency, 1),
|
price: new Price($amount, $cart->currency, 1),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
|||||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||||
use Lunar\Shipping\Models\ShippingRate;
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Box Now has no pricing API, so this always resolves the method's normal
|
* Box Now has no pricing API, so this always resolves the method's normal
|
||||||
@@ -14,7 +15,7 @@ use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
|||||||
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
|
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
|
||||||
* there is no live option to offer.
|
* there is no live option to offer.
|
||||||
*/
|
*/
|
||||||
class BoxNowRateDriver implements ShippingRateInterface
|
class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
|
||||||
{
|
{
|
||||||
use ResolvesFixedPricing;
|
use ResolvesFixedPricing;
|
||||||
|
|
||||||
@@ -25,6 +26,11 @@ class BoxNowRateDriver implements ShippingRateInterface
|
|||||||
return 'Box Now Locker Delivery';
|
return 'Box Now Locker Delivery';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fulfillmentType(): string
|
||||||
|
{
|
||||||
|
return 'carrier';
|
||||||
|
}
|
||||||
|
|
||||||
public function description(): string
|
public function description(): string
|
||||||
{
|
{
|
||||||
return 'Deliver to a Box Now parcel locker.';
|
return 'Deliver to a Box Now parcel locker.';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use Lunar\DataTypes\ShippingOption;
|
|||||||
use Lunar\Facades\Pricing;
|
use Lunar\Facades\Pricing;
|
||||||
use Lunar\Shipping\Models\ShippingMethod;
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
use Lunar\Shipping\Models\ShippingRate;
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
use Modules\Core\Shipping\Support\FulfillmentType;
|
||||||
|
use Modules\Core\Shipping\Support\ShippingMethodName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared by any carrier driver that also supports Lunar's own price-break
|
* Shared by any carrier driver that also supports Lunar's own price-break
|
||||||
@@ -31,12 +33,13 @@ trait ResolvesFixedPricing
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new ShippingOption(
|
return new ShippingOption(
|
||||||
name: $shippingMethod->name ?: $this->name(),
|
name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
|
||||||
description: $shippingMethod->description ?: $this->description(),
|
description: $shippingMethod->description ?: $this->description(),
|
||||||
identifier: $shippingRate->getIdentifier(),
|
identifier: $shippingRate->getIdentifier(),
|
||||||
price: $pricing->matched->price,
|
price: $pricing->matched->price,
|
||||||
taxClass: $shippingRate->getTaxClass(),
|
taxClass: $shippingRate->getTaxClass(),
|
||||||
taxReference: $shippingRate->getTaxReference(),
|
taxReference: $shippingRate->getTaxReference(),
|
||||||
|
collect: FulfillmentType::isStorePickup($shippingMethod),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional contract a shipping rate driver implements to declare whether
|
||||||
|
* it fulfils via carrier delivery or in-store pickup — e.g.
|
||||||
|
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver
|
||||||
|
* are unambiguously carrier-only, so this is a hardcoded fact about the
|
||||||
|
* driver, not something a merchant should have to configure per row.
|
||||||
|
*
|
||||||
|
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
|
||||||
|
* free-shipping) don't implement this — they're genuinely ambiguous (a
|
||||||
|
* merchant could configure one for either carrier delivery or store
|
||||||
|
* pickup), so Modules\Core\Shipping\Support\FulfillmentType::resolve()
|
||||||
|
* falls back to ShippingMethod.data['fulfillment_type'] (still merchant-
|
||||||
|
* overridable) only for drivers that don't implement this contract.
|
||||||
|
*/
|
||||||
|
interface DeclaresFulfillmentType
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return 'carrier'|'store_pickup'
|
||||||
|
*/
|
||||||
|
public function fulfillmentType(): string;
|
||||||
|
}
|
||||||
@@ -11,37 +11,110 @@ use Filament\Forms\Components\Select;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||||
|
use Lunar\Admin\Support\Forms\Components\TranslatedText;
|
||||||
use Lunar\Shipping\Facades\Shipping;
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||||
|
use Modules\Core\Shipping\Support\ShippingMethodName;
|
||||||
|
|
||||||
class ShippingMethodResourceExtension extends ResourceExtension
|
class ShippingMethodResourceExtension extends ResourceExtension
|
||||||
{
|
{
|
||||||
public function extendForm(Schema $schema): Schema
|
public function extendForm(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components(
|
||||||
...$this->replaceChargeByField(
|
$this->replaceFulfillmentTypeField(
|
||||||
$this->replaceDriverField($schema->getComponents())
|
$this->replaceChargeByField(
|
||||||
),
|
$this->replaceNameField(
|
||||||
$this->fulfillmentTypeSelect(),
|
$this->replaceDriverField($schema->getComponents())
|
||||||
]);
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ShippingMethod.data['fulfillment_type'] — 'carrier' (default) or
|
* Replaces the vendor's plain-string `name` TextInput with
|
||||||
* 'store_pickup'. Same free-form-`data`-column pattern as charge_by
|
* Lunar's own TranslatedText — `name` is now a locale-keyed JSON
|
||||||
* above, not a migrated column: ShippingMethod is a vendor
|
* column (see database/migrations/..._make_shipping_methods_name_translatable.php),
|
||||||
* (lunarphp/table-rate-shipping) table, and this codebase avoids
|
* same shape/resolution as PaymentMethod.name and Product/Collection
|
||||||
* forking vendor migrations for a merchant-configurable extra (see
|
* names (Lunar\Base\Traits\HasTranslations).
|
||||||
* PaymentMethod.data.fee for the same convention on a different
|
|
||||||
* vendor-adjacent model).
|
|
||||||
*
|
|
||||||
* What this actually gates: Modules\Core\Shipping\Extensions\
|
|
||||||
* OrderViewExtension's "Create Shipment" action only makes sense for
|
|
||||||
* a 'carrier' method (it books a real carrier voucher) — a
|
|
||||||
* 'store_pickup' order instead moves through Order.status
|
|
||||||
* 'ready-for-pickup' -> a staff "Mark Picked Up" action, no shipment
|
|
||||||
* ever created. See docs/checkout.md for the full status-flow design.
|
|
||||||
*/
|
*/
|
||||||
|
private function replaceNameField(array $components): array
|
||||||
|
{
|
||||||
|
return array_map(function (Component $component) {
|
||||||
|
if (method_exists($component, 'getName') && $component->getName() === 'name') {
|
||||||
|
return $this->translatedNameField();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
|
||||||
|
$component->schema($this->replaceNameField($component->getChildComponents()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $component;
|
||||||
|
}, $components);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShippingMethod.name is a locale-keyed JSON column (see database/
|
||||||
|
* migrations/..._make_shipping_methods_name_translatable.php), but
|
||||||
|
* ShippingMethod is a vendor Eloquent model with no cast declared for
|
||||||
|
* it — Lunar\Shipping\Models\ShippingMethod only casts `data`, and
|
||||||
|
* there's no ModelManifest contract wired up to swap in a first-party
|
||||||
|
* subclass that adds one (Contracts\ShippingMethod exists but is
|
||||||
|
* never bound — see this class's own git history/nameColumn() for
|
||||||
|
* the same gap on the read side). TranslatedText itself round-trips
|
||||||
|
* plain array state, so afterStateHydrated()/dehydrateStateUsing()
|
||||||
|
* decode/encode the raw JSON string at the field boundary instead —
|
||||||
|
* the model attribute is a string on the way in and out, only ever
|
||||||
|
* an array while Filament's schema state holds it.
|
||||||
|
*/
|
||||||
|
private function translatedNameField(): TranslatedText
|
||||||
|
{
|
||||||
|
$field = TranslatedText::make('name')
|
||||||
|
->label('Name')
|
||||||
|
->required()
|
||||||
|
->afterStateHydrated(function (TranslatedText $component, $state) {
|
||||||
|
$decoded = json_decode((string) $state, true);
|
||||||
|
$component->state(is_array($decoded) ? $decoded : []);
|
||||||
|
})
|
||||||
|
->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : []));
|
||||||
|
|
||||||
|
$field->expanded = true;
|
||||||
|
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts the `fulfillment_type` Select right after `charge_by`, in
|
||||||
|
* the SAME Group (vendor's own `Group::make([getChargeByFormComponent()])
|
||||||
|
* ->columns(2)`) — formerly appended at the very end of the whole
|
||||||
|
* form, disconnected from `driver`/`charge_by`, the decisions it
|
||||||
|
* actually relates to. Only rendered at all for a driver that DOESN'T
|
||||||
|
* already declare its own fulfillment type (see Modules\Core\Shipping\
|
||||||
|
* Contracts\DeclaresFulfillmentType, Modules\Core\Shipping\Support\
|
||||||
|
* FulfillmentType) — acs/box-now are unambiguously carrier-only, so
|
||||||
|
* asking a merchant to also pick "Carrier delivery" for every ACS/Box
|
||||||
|
* Now method was redundant, error-prone config with no real decision
|
||||||
|
* behind it. Still offered for table-rate-shipping's generic drivers
|
||||||
|
* (flat-rate, ship-by, free-shipping), which are genuinely ambiguous.
|
||||||
|
*/
|
||||||
|
private function replaceFulfillmentTypeField(array $components): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
$result[] = $component;
|
||||||
|
|
||||||
|
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
|
||||||
|
$result[] = $this->fulfillmentTypeSelect();
|
||||||
|
} elseif (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
|
||||||
|
$component->schema($this->replaceFulfillmentTypeField($component->getChildComponents()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
private function fulfillmentTypeSelect(): Select
|
private function fulfillmentTypeSelect(): Select
|
||||||
{
|
{
|
||||||
return Select::make('data.fulfillment_type')
|
return Select::make('data.fulfillment_type')
|
||||||
@@ -52,9 +125,23 @@ class ShippingMethodResourceExtension extends ResourceExtension
|
|||||||
])
|
])
|
||||||
->default('carrier')
|
->default('carrier')
|
||||||
->required()
|
->required()
|
||||||
|
->visible(fn (Get $get) => $this->driverIsFulfillmentAmbiguous($get('../driver')))
|
||||||
->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.');
|
->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function driverIsFulfillmentAmbiguous(?string $driver): bool
|
||||||
|
{
|
||||||
|
if (! $driver) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ! Shipping::driver($driver) instanceof DeclaresFulfillmentType;
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extend the vendor's cart_total/weight charge_by Select with a third
|
* Extend the vendor's cart_total/weight charge_by Select with a third
|
||||||
* "live" option — only offered when the currently selected driver
|
* "live" option — only offered when the currently selected driver
|
||||||
@@ -127,6 +214,10 @@ class ShippingMethodResourceExtension extends ResourceExtension
|
|||||||
return $this->driverColumn();
|
return $this->driverColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method_exists($column, 'getName') && $column->getName() === 'name') {
|
||||||
|
return $this->nameColumn();
|
||||||
|
}
|
||||||
|
|
||||||
return $column;
|
return $column;
|
||||||
}, $table->getColumns())
|
}, $table->getColumns())
|
||||||
);
|
);
|
||||||
@@ -139,6 +230,21 @@ class ShippingMethodResourceExtension extends ResourceExtension
|
|||||||
->formatStateUsing(fn ($state) => $this->driverLabel($state));
|
->formatStateUsing(fn ($state) => $this->driverLabel($state));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `name` is a locale-keyed JSON column (see Modules\Core\Shipping\
|
||||||
|
* Support\ShippingMethodName's own docblock for why it needs manual
|
||||||
|
* decoding rather than a model cast). Uses ->state() rather than
|
||||||
|
* ->formatStateUsing(), which would otherwise have Filament iterate a
|
||||||
|
* would-be array state as a multi-value list (one formatted cell per
|
||||||
|
* locale) instead of a single string.
|
||||||
|
*/
|
||||||
|
private function nameColumn(): TextColumn
|
||||||
|
{
|
||||||
|
return TextColumn::make('name')
|
||||||
|
->label('Name')
|
||||||
|
->state(fn ($record) => ShippingMethodName::resolve($record));
|
||||||
|
}
|
||||||
|
|
||||||
private function driverLabel(string $key): string
|
private function driverLabel(string $key): string
|
||||||
{
|
{
|
||||||
$driver = collect(Shipping::getSupportedDrivers())->get($key);
|
$driver = collect(Shipping::getSupportedDrivers())->get($key);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Support;
|
||||||
|
|
||||||
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single source of truth for "is this ShippingMethod a carrier
|
||||||
|
* delivery or an in-store pickup" — replaces a merchant-facing
|
||||||
|
* data['fulfillment_type'] Select that used to exist for every method
|
||||||
|
* regardless of driver. Modules\Core\Shipping\Carriers\Acs\AcsRateDriver
|
||||||
|
* and BoxNowRateDriver are unambiguously carrier-only (see
|
||||||
|
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType's own
|
||||||
|
* docblock), so asking a merchant to also pick "Carrier delivery" for
|
||||||
|
* every ACS/Box Now method was redundant, error-prone config with no
|
||||||
|
* real decision behind it.
|
||||||
|
*
|
||||||
|
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
|
||||||
|
* free-shipping) don't implement DeclaresFulfillmentType — a merchant
|
||||||
|
* could genuinely configure one for either purpose (e.g. "Flat Rate —
|
||||||
|
* Athens Store Pickup") — so those still fall back to the merchant-set
|
||||||
|
* data['fulfillment_type'], defaulting to 'carrier' when unset.
|
||||||
|
*/
|
||||||
|
class FulfillmentType
|
||||||
|
{
|
||||||
|
public static function resolve(ShippingMethod $method): string
|
||||||
|
{
|
||||||
|
$driver = collect(Shipping::getSupportedDrivers())->get($method->driver);
|
||||||
|
|
||||||
|
if ($driver instanceof DeclaresFulfillmentType) {
|
||||||
|
return $driver->fulfillmentType();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $method->data['fulfillment_type'] ?? 'carrier';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isStorePickup(ShippingMethod $method): bool
|
||||||
|
{
|
||||||
|
return static::resolve($method) === 'store_pickup';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the merchant-facing "Fulfillment type" Select should be
|
||||||
|
* shown at all for a given driver — hidden entirely for a driver that
|
||||||
|
* already declares its own fulfillment type, since there is no real
|
||||||
|
* decision left for the merchant to make.
|
||||||
|
*/
|
||||||
|
public static function isConfigurableFor(?string $driverKey): bool
|
||||||
|
{
|
||||||
|
if ($driverKey === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$driver = collect(Shipping::getSupportedDrivers())->get($driverKey);
|
||||||
|
|
||||||
|
return ! $driver instanceof DeclaresFulfillmentType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Support;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShippingMethod.name is a locale-keyed JSON column (see database/
|
||||||
|
* migrations/..._make_shipping_methods_name_translatable.php, and
|
||||||
|
* Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension for
|
||||||
|
* the Filament form/table side), but ShippingMethod is a vendor Eloquent
|
||||||
|
* model with no cast declared for it — Lunar\Shipping\Models\
|
||||||
|
* ShippingMethod only casts `data`, and there's no ModelManifest contract
|
||||||
|
* wired up by the package to swap in a first-party subclass that adds
|
||||||
|
* one (Contracts\ShippingMethod exists but is never bound). So `$method->
|
||||||
|
* name` returns the raw JSON string, not a decoded array — this resolves
|
||||||
|
* it the same way Lunar\Base\Traits\HasTranslations::translate() would,
|
||||||
|
* shared by every rate driver that builds a Lunar\DataTypes\ShippingOption
|
||||||
|
* (Modules\Core\Shipping\Concerns\ResolvesFixedPricing, Modules\Core\
|
||||||
|
* Shipping\Carriers\Acs\AcsRateDriver) plus the Filament table column.
|
||||||
|
*/
|
||||||
|
class ShippingMethodName
|
||||||
|
{
|
||||||
|
public static function resolve(ShippingMethod $method, ?string $locale = null): ?string
|
||||||
|
{
|
||||||
|
$decoded = json_decode((string) $method->getRawOriginal('name'), true);
|
||||||
|
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
return $method->getRawOriginal('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Arr::get($decoded, $locale ?: app()->getLocale()) ?: Arr::first($decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user