Compare commits
11 Commits
v0.2.0
...
d34e450526
| Author | SHA1 | Date | |
|---|---|---|---|
| d34e450526 | |||
| 4acabe4185 | |||
| 37c2e1194c | |||
| 89255687a1 | |||
| 3599329b57 | |||
| 9618d19cfa | |||
| 08135c4c8f | |||
| e18b2fa44c | |||
| 4c00369005 | |||
| cf6aa343f1 | |||
| ee9893bd64 |
@@ -4,6 +4,13 @@ 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.3.0] - 2026-07-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Meilisearch product search**: pulled in `lunarphp/search` (Lunar's driver-agnostic search abstraction — `database`/`meilisearch`/`typesense` engines, selectable via Scout's own `SCOUT_DRIVER` config) and `lunarphp/meilisearch`, wiring Meilisearch in as the search engine for products.
|
||||||
|
- `Search\ProductIndexer` overrides Lunar's own indexer to strip HTML tags from string fields (e.g. `name_en`, `description_en`) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup.
|
||||||
|
- Meilisearch itself is treated as app-level infrastructure, not a `boboko-core` concern: the actual Meilisearch container, host port, and master key live in each consuming app's own `docker-compose.yml`/`.env` (e.g. `3dealer`), the same way Postgres and Valkey do — `boboko-core` only declares the PHP package dependency and the indexing code.
|
||||||
|
|
||||||
## [0.2.0] - 2026-07-10
|
## [0.2.0] - 2026-07-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+6
-3
@@ -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.2.0",
|
"version": "0.3.0",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"symfony/yaml": "^7.0",
|
"symfony/yaml": "^7.0",
|
||||||
"lunarphp/table-rate-shipping": "^1.3"
|
"lunarphp/table-rate-shipping": "^1.3",
|
||||||
|
"lunarphp/search": "*",
|
||||||
|
"lunarphp/meilisearch": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
@@ -31,7 +33,8 @@
|
|||||||
"providers": [
|
"providers": [
|
||||||
"Modules\\Core\\Providers\\CoreServiceProvider",
|
"Modules\\Core\\Providers\\CoreServiceProvider",
|
||||||
"Modules\\Core\\Providers\\AuthServiceProvider",
|
"Modules\\Core\\Providers\\AuthServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CustomerServiceProvider"
|
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\ShippingServiceProvider"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Search Engine
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default search connection that gets used while
|
||||||
|
| using Laravel Scout. This connection is used when syncing all models
|
||||||
|
| to the search service. You should adjust this based on your needs.
|
||||||
|
|
|
||||||
|
| Supported: "algolia", "meilisearch", "typesense",
|
||||||
|
| "database", "collection", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => env('SCOUT_DRIVER', 'collection'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Index Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify a prefix that will be applied to all search index
|
||||||
|
| names used by Scout. This prefix may be useful if you have multiple
|
||||||
|
| "tenants" or applications sharing the same search infrastructure.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('SCOUT_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Data Syncing
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to control if the operations that sync your data
|
||||||
|
| with your search engines are queued. When this is set to "true" then
|
||||||
|
| all automatic data syncing will get queued for better performance.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'queue' => env('SCOUT_QUEUE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Transactions
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This configuration option determines if your data will only be synced
|
||||||
|
| with your search indexes after every open database transaction has
|
||||||
|
| been committed, thus preventing any discarded data from syncing.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'after_commit' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Chunk Sizes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options allow you to control the maximum chunk size when you are
|
||||||
|
| mass importing data into the search engine. This allows you to fine
|
||||||
|
| tune each of these chunk sizes based on the power of the servers.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'chunk' => [
|
||||||
|
'searchable' => 500,
|
||||||
|
'unsearchable' => 500,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Soft Deletes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows to control whether to keep soft deleted records in
|
||||||
|
| the search indexes. Maintaining soft deleted records can be useful
|
||||||
|
| if your application still needs to search for the records later.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'soft_delete' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Identify User
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to control whether to notify the search engine
|
||||||
|
| of the user performing the search. This is sometimes useful if the
|
||||||
|
| engine supports any analytics based on this application's users.
|
||||||
|
|
|
||||||
|
| Supported engines: "algolia"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'identify' => env('SCOUT_IDENTIFY', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Algolia Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
|
||||||
|
| search engine which works great with Scout out of the box. Just plug
|
||||||
|
| in your application ID and admin API key to get started searching.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'algolia' => [
|
||||||
|
'id' => env('ALGOLIA_APP_ID', ''),
|
||||||
|
'secret' => env('ALGOLIA_SECRET', ''),
|
||||||
|
'index-settings' => [
|
||||||
|
// 'users' => [
|
||||||
|
// 'searchableAttributes' => ['id', 'name', 'email'],
|
||||||
|
// 'attributesForFaceting'=> ['filterOnly(email)'],
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Meilisearch Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
|
||||||
|
| source search engine with minimal configuration. Below, you can state
|
||||||
|
| the host and key information for your own Meilisearch installation.
|
||||||
|
|
|
||||||
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'meilisearch' => [
|
||||||
|
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
|
||||||
|
'key' => env('MEILISEARCH_KEY'),
|
||||||
|
'index-settings' => [
|
||||||
|
// 'users' => [
|
||||||
|
// 'filterableAttributes'=> ['id', 'name', 'email'],
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Typesense Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your Typesense settings. Typesense is an open
|
||||||
|
| source search engine using minimal configuration. Below, you will
|
||||||
|
| state the host, key, and schema configuration for the instance.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'typesense' => [
|
||||||
|
'client-settings' => [
|
||||||
|
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
|
||||||
|
'nodes' => [
|
||||||
|
[
|
||||||
|
'host' => env('TYPESENSE_HOST', 'localhost'),
|
||||||
|
'port' => env('TYPESENSE_PORT', '8108'),
|
||||||
|
'path' => env('TYPESENSE_PATH', ''),
|
||||||
|
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'nearest_node' => [
|
||||||
|
'host' => env('TYPESENSE_HOST', 'localhost'),
|
||||||
|
'port' => env('TYPESENSE_PORT', '8108'),
|
||||||
|
'path' => env('TYPESENSE_PATH', ''),
|
||||||
|
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
|
||||||
|
],
|
||||||
|
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
|
||||||
|
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
|
||||||
|
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
|
||||||
|
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
|
||||||
|
],
|
||||||
|
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
|
||||||
|
'model-settings' => [
|
||||||
|
// User::class => [
|
||||||
|
// 'collection-schema' => [
|
||||||
|
// 'fields' => [
|
||||||
|
// [
|
||||||
|
// 'name' => 'id',
|
||||||
|
// 'type' => 'string',
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// 'name' => 'name',
|
||||||
|
// 'type' => 'string',
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// 'name' => 'created_at',
|
||||||
|
// 'type' => 'int64',
|
||||||
|
// ],
|
||||||
|
// ],
|
||||||
|
// 'default_sorting_field' => 'created_at',
|
||||||
|
// ],
|
||||||
|
// 'search-parameters' => [
|
||||||
|
// 'query_by' => 'name'
|
||||||
|
// ],
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| ACS Courier credentials
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
|
||||||
|
| HTTP header (gates the REST gateway itself) and four account fields
|
||||||
|
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
|
||||||
|
| request body. Both are supplied by ACS when your account is set up.
|
||||||
|
|
|
||||||
|
| Set these via environment variables — never commit real values.
|
||||||
|
|
|
||||||
|
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
|
||||||
|
| every ACSAlias call).
|
||||||
|
| ACS_API_KEY The AcsApiKey header value.
|
||||||
|
| ACS_COMPANY_ID Company_ID body field.
|
||||||
|
| ACS_COMPANY_PASSWORD Company_Password body field.
|
||||||
|
| ACS_USER_ID User_ID body field.
|
||||||
|
| ACS_USER_PASSWORD User_Password body field.
|
||||||
|
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
|
||||||
|
| calculation and voucher creation.
|
||||||
|
| ACS_SENDER_* Static sender details reused on every voucher.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
|
||||||
|
|
||||||
|
'api_key' => env('ACS_API_KEY'),
|
||||||
|
|
||||||
|
'company_id' => env('ACS_COMPANY_ID'),
|
||||||
|
'company_password' => env('ACS_COMPANY_PASSWORD'),
|
||||||
|
'user_id' => env('ACS_USER_ID'),
|
||||||
|
'user_password' => env('ACS_USER_PASSWORD'),
|
||||||
|
|
||||||
|
'billing_code' => env('ACS_BILLING_CODE'),
|
||||||
|
|
||||||
|
'sender' => [
|
||||||
|
'name' => env('ACS_SENDER_NAME'),
|
||||||
|
'address' => env('ACS_SENDER_ADDRESS'),
|
||||||
|
'zip_code' => env('ACS_SENDER_ZIP'),
|
||||||
|
'phone' => env('ACS_SENDER_PHONE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Box Now credentials
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
|
||||||
|
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
|
||||||
|
| ~1hr expiry), then attach it as an Authorization header on every call.
|
||||||
|
| Unlike ACS, there is no separate per-request credential body — the
|
||||||
|
| token alone authorizes all calls once obtained.
|
||||||
|
|
|
||||||
|
| Set these via environment variables — never commit real values.
|
||||||
|
|
|
||||||
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
|
||||||
|
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
|
||||||
|
| lookups (Box Now recommends this over the main
|
||||||
|
| base URL for those two calls specifically).
|
||||||
|
| BOXNOW_CLIENT_ID OAuth2 client id.
|
||||||
|
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
|
||||||
|
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
|
||||||
|
| the pickup origin on every delivery request.
|
||||||
|
| BOXNOW_SENDER_* Static sender contact details reused on every
|
||||||
|
| delivery request.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
|
||||||
|
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
|
||||||
|
|
||||||
|
'client_id' => env('BOXNOW_CLIENT_ID'),
|
||||||
|
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
|
||||||
|
|
||||||
|
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
|
||||||
|
|
||||||
|
'sender' => [
|
||||||
|
'name' => env('BOXNOW_SENDER_NAME'),
|
||||||
|
'email' => env('BOXNOW_SENDER_EMAIL'),
|
||||||
|
'phone' => env('BOXNOW_SENDER_PHONE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('shipments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('order_id')->constrained(config('lunar.database.table_prefix').'orders');
|
||||||
|
$table->string('carrier');
|
||||||
|
$table->string('tracking_reference')->unique();
|
||||||
|
$table->string('parent_reference')->nullable();
|
||||||
|
$table->timestamp('label_printed_at')->nullable();
|
||||||
|
$table->string('manifest_reference')->nullable();
|
||||||
|
$table->timestamp('cancelled_at')->nullable();
|
||||||
|
$table->json('meta')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('shipments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament-panels::page>
|
||||||
+12
-1
@@ -6,17 +6,24 @@ use Filament\Contracts\Plugin;
|
|||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Lunar\Admin\Filament\Resources\OrderResource;
|
||||||
use Lunar\Admin\Filament\Resources\ProductResource;
|
use Lunar\Admin\Filament\Resources\ProductResource;
|
||||||
use Lunar\Admin\Filament\Resources\StaffResource;
|
use Lunar\Admin\Filament\Resources\StaffResource;
|
||||||
use Lunar\Admin\Models\Staff as LunarStaff;
|
use Lunar\Admin\Models\Staff as LunarStaff;
|
||||||
use Lunar\Admin\Support\Facades\LunarPanel;
|
use Lunar\Admin\Support\Facades\LunarPanel;
|
||||||
use Lunar\Models\Product;
|
use Lunar\Models\Product;
|
||||||
|
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
|
||||||
|
use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod;
|
||||||
use Lunar\Shipping\ShippingPlugin;
|
use Lunar\Shipping\ShippingPlugin;
|
||||||
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
||||||
use Modules\Core\Auth\Filament\Pages\Login;
|
use Modules\Core\Auth\Filament\Pages\Login;
|
||||||
use Modules\Core\Auth\Mail\InviteMail;
|
use Modules\Core\Auth\Mail\InviteMail;
|
||||||
use Modules\Core\Review\Extensions\ProductResourceExtension;
|
use Modules\Core\Review\Extensions\ProductResourceExtension;
|
||||||
use Modules\Core\Review\Models\ProductReview;
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
use Modules\Core\Shipping\Extensions\OrderViewExtension;
|
||||||
|
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
|
||||||
|
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
|
||||||
|
use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
|
||||||
|
|
||||||
class CorePlugin implements Plugin
|
class CorePlugin implements Plugin
|
||||||
{
|
{
|
||||||
@@ -32,11 +39,15 @@ class CorePlugin implements Plugin
|
|||||||
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
|
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
|
||||||
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
|
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->plugin(ShippingPlugin::make());
|
->plugin(ShippingPlugin::make())
|
||||||
|
->pages([ManagePickupManifests::class]);
|
||||||
|
|
||||||
LunarPanel::extensions([
|
LunarPanel::extensions([
|
||||||
StaffResource::class => StaffResourceExtension::class,
|
StaffResource::class => StaffResourceExtension::class,
|
||||||
ProductResource::class => ProductResourceExtension::class,
|
ProductResource::class => ProductResourceExtension::class,
|
||||||
|
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
|
||||||
|
ListShippingMethod::class => ShippingMethodListExtension::class,
|
||||||
|
OrderResource\Pages\ManageOrder::class => OrderViewExtension::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Product::macro('reviews', function (): HasMany {
|
Product::macro('reviews', function (): HasMany {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
||||||
|
__DIR__ . '/../../config/scout.php' => config_path('scout.php'),
|
||||||
], 'core-config');
|
], 'core-config');
|
||||||
|
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Livewire\Mechanisms\ComponentRegistry;
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
|
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
|
||||||
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\AcsClient;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
|
||||||
|
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
|
||||||
|
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
|
||||||
|
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
|
||||||
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
|
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
||||||
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
|
class ShippingServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
|
||||||
|
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
|
||||||
|
|
||||||
|
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
|
||||||
|
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
|
||||||
|
|
||||||
|
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
|
||||||
|
return match ($params['carrier'] ?? null) {
|
||||||
|
'acs' => $app->make(AcsFulfillmentService::class),
|
||||||
|
'box-now' => $app->make(BoxNowFulfillmentService::class),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The vendor Rates page has no extension hook, so we swap it for
|
||||||
|
// our subclass everywhere. Route::get($path, VendorClass::class)
|
||||||
|
// instantiates the vendor class directly via the container for the
|
||||||
|
// initial full-page load (bypassing Livewire's component registry
|
||||||
|
// entirely), so this container bind is required in addition to the
|
||||||
|
// Livewire::component() re-registration below — the bind covers
|
||||||
|
// first load, the Livewire registration covers every AJAX
|
||||||
|
// round-trip (form submits, table interactions) afterwards.
|
||||||
|
$this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->publishes([
|
||||||
|
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
|
||||||
|
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
|
||||||
|
], 'core-config');
|
||||||
|
|
||||||
|
Order::resolveRelationUsing('shipments', function ($order) {
|
||||||
|
return $order->hasMany(Shipment::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deferred: the Shipping facade resolves a binding registered in
|
||||||
|
// lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(),
|
||||||
|
// and provider boot order between packages isn't guaranteed.
|
||||||
|
$this->app->booted(function () {
|
||||||
|
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
|
||||||
|
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
|
||||||
|
|
||||||
|
$this->app->make(ConsoleSchedule::class)
|
||||||
|
->job(new WarmAcsAreaCacheJob)
|
||||||
|
->dailyAt('06:00')
|
||||||
|
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
|
||||||
|
|
||||||
|
$this->overrideRatesPageLivewireComponent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vendor Rates page has no extension hook, so we swap it for our
|
||||||
|
* subclass (see Shipping/Filament/Pages/ManageShippingRates). Filament
|
||||||
|
* already registered the vendor class as a Livewire component under a
|
||||||
|
* name derived from its class string (see
|
||||||
|
* Panel::registerLivewireComponents()); Livewire's own registry is a
|
||||||
|
* simple last-write-wins name => class map, so re-registering the same
|
||||||
|
* derived name against our subclass here overrides it — keeping the
|
||||||
|
* route, sub-navigation, and every Livewire round-trip (including form
|
||||||
|
* submissions) pointed at one consistent component identity.
|
||||||
|
*/
|
||||||
|
private function overrideRatesPageLivewireComponent(): void
|
||||||
|
{
|
||||||
|
$name = $this->app->make(ComponentRegistry::class)->getName(VendorManageShippingRates::class);
|
||||||
|
|
||||||
|
Livewire::component($name, ManageShippingRates::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Search;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Lunar\Search\ProductIndexer as BaseProductIndexer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lunar's own indexer puts raw attribute HTML (e.g. name_en, description_en) into
|
||||||
|
* the search index, which pollutes relevance ranking and highlighting with markup.
|
||||||
|
* Strip tags from string fields before they reach Meilisearch.
|
||||||
|
*/
|
||||||
|
class ProductIndexer extends BaseProductIndexer
|
||||||
|
{
|
||||||
|
public function toSearchableArray(Model $model): array
|
||||||
|
{
|
||||||
|
$data = parent::toSearchableArray($model);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_string($value)) {
|
||||||
|
$data[$key] = trim(strip_tags($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
class AcsArea
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $stationId,
|
||||||
|
public readonly int $branchId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class AcsClient
|
||||||
|
{
|
||||||
|
public function __construct(private readonly array $config) {}
|
||||||
|
|
||||||
|
public function call(string $alias, array $parameters = []): AcsResponse
|
||||||
|
{
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'AcsApiKey' => $this->config['api_key'],
|
||||||
|
])
|
||||||
|
->timeout($this->config['timeout'])
|
||||||
|
->post($this->config['base_url'], [
|
||||||
|
'ACSAlias' => $alias,
|
||||||
|
'ACSInputParameters' => array_merge($this->credentialParams(), $parameters),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return AcsResponse::fromHttpResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function credentialParams(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Company_ID' => $this->config['company_id'],
|
||||||
|
'Company_Password' => $this->config['company_password'],
|
||||||
|
'User_ID' => $this->config['user_id'],
|
||||||
|
'User_Password' => $this->config['user_password'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
|
||||||
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
|
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AcsClient $client,
|
||||||
|
private readonly AreaResolver $areaResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function createShipment(Order $order, array $overrides = []): Shipment
|
||||||
|
{
|
||||||
|
$address = $order->shippingAddress;
|
||||||
|
$destination = $this->areaResolver->resolve($address->postcode);
|
||||||
|
|
||||||
|
$response = $this->client->call('ACS_Create_Voucher', array_merge([
|
||||||
|
'Pickup_Date' => now()->toDateString(),
|
||||||
|
'Sender' => config('acs.sender.name'),
|
||||||
|
'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"),
|
||||||
|
'Recipient_Address' => $address->line_one,
|
||||||
|
'Recipient_Zipcode' => $address->postcode,
|
||||||
|
'Recipient_Region' => $address->city,
|
||||||
|
'Recipient_Phone' => $address->contact_phone,
|
||||||
|
'Recipient_Country' => 'GR',
|
||||||
|
'Acs_Station_Branch_Destination' => $destination->branchId,
|
||||||
|
'Billing_Code' => config('acs.billing_code'),
|
||||||
|
'Charge_Type' => 2,
|
||||||
|
'Item_Quantity' => 1,
|
||||||
|
'Weight' => 0.5,
|
||||||
|
], $overrides))->throwIfError();
|
||||||
|
|
||||||
|
$voucherNo = (string) $response->valueOutput['Voucher_No'];
|
||||||
|
|
||||||
|
$shipment = Shipment::create([
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'carrier' => 'acs',
|
||||||
|
'tracking_reference' => $voucherNo,
|
||||||
|
'meta' => [
|
||||||
|
'station_destination' => $destination->stationId,
|
||||||
|
'weight' => $overrides['Weight'] ?? 0.5,
|
||||||
|
'pickup_date' => now()->toDateString(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (($overrides['Item_Quantity'] ?? 1) > 1) {
|
||||||
|
$this->persistMultipartVouchers($shipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $shipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function printLabel(Shipment $shipment): string
|
||||||
|
{
|
||||||
|
$response = $this->client->call('ACS_Print_Voucher', [
|
||||||
|
'Voucher_No' => $shipment->tracking_reference,
|
||||||
|
'Print_Type' => 2,
|
||||||
|
'Start_Position' => 1,
|
||||||
|
])->throwIfError();
|
||||||
|
|
||||||
|
$shipment->update(['label_printed_at' => now()]);
|
||||||
|
|
||||||
|
return $response->valueOutput[$shipment->tracking_reference] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelShipment(Shipment $shipment): void
|
||||||
|
{
|
||||||
|
if ($shipment->manifest_reference) {
|
||||||
|
throw new \RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->client->call('ACS_Delete_Voucher', [
|
||||||
|
'Voucher_No' => $shipment->tracking_reference,
|
||||||
|
])->throwIfError();
|
||||||
|
|
||||||
|
$shipment->update(['cancelled_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingForManifest(): Collection
|
||||||
|
{
|
||||||
|
return Shipment::query()
|
||||||
|
->where('carrier', 'acs')
|
||||||
|
->whereNull('manifest_reference')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function issueManifest(Collection $shipments): ManifestResult
|
||||||
|
{
|
||||||
|
$unprinted = $shipments->whereNull('label_printed_at');
|
||||||
|
|
||||||
|
if ($unprinted->isNotEmpty()) {
|
||||||
|
return ManifestResult::blocked($unprinted, 'unprinted');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->client->call('ACS_Issue_Pickup_List', [
|
||||||
|
'Pickup_Date' => now()->toDateString(),
|
||||||
|
'MyData' => null,
|
||||||
|
])->throwIfError();
|
||||||
|
|
||||||
|
$pickupListNo = (string) $response->valueOutput['PickupList_No'];
|
||||||
|
|
||||||
|
$shipments->each(fn (Shipment $shipment) => $shipment->update([
|
||||||
|
'manifest_reference' => $pickupListNo,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return ManifestResult::success($pickupListNo, $shipments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistMultipartVouchers(Shipment $mainShipment): void
|
||||||
|
{
|
||||||
|
$response = $this->client->call('ACS_Get_Multipart_Vouchers', [
|
||||||
|
'Main_Voucher_No' => $mainShipment->tracking_reference,
|
||||||
|
])->throwIfError();
|
||||||
|
|
||||||
|
foreach ($response->tableOutput['Table_Data'] ?? [] as $row) {
|
||||||
|
Shipment::create([
|
||||||
|
'order_id' => $mainShipment->order_id,
|
||||||
|
'carrier' => 'acs',
|
||||||
|
'tracking_reference' => $row['MultiPart_Voucher_No'],
|
||||||
|
'parent_reference' => $mainShipment->tracking_reference,
|
||||||
|
'meta' => $mainShipment->meta?->toArray() ?? [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\Price;
|
||||||
|
use Lunar\DataTypes\ShippingOption;
|
||||||
|
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||||
|
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||||
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||||
|
|
||||||
|
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||||
|
{
|
||||||
|
use ResolvesFixedPricing;
|
||||||
|
|
||||||
|
public ShippingRate $shippingRate;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AcsClient $client,
|
||||||
|
private readonly AreaResolver $areaResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'ACS Courier';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Live rate quote from ACS Courier.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
|
||||||
|
{
|
||||||
|
$shippingRate = $shippingOptionRequest->shippingRate;
|
||||||
|
$shippingMethod = $shippingRate->shippingMethod;
|
||||||
|
$cart = $shippingOptionRequest->cart;
|
||||||
|
|
||||||
|
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
|
||||||
|
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
$postcode = $cart->shippingAddress?->postcode;
|
||||||
|
|
||||||
|
if (! $postcode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$destination = $this->areaResolver->resolve($postcode);
|
||||||
|
|
||||||
|
$response = $this->client->call('ACS_Price_Calculation', [
|
||||||
|
'Billing_Code' => config('acs.billing_code'),
|
||||||
|
'Acs_Station_Destination' => $destination->stationId,
|
||||||
|
'Weight' => $this->totalWeightInKg($cart),
|
||||||
|
'Pickup_Date' => now()->toDateString(),
|
||||||
|
'Charge_Type' => 2,
|
||||||
|
])->throwIfError();
|
||||||
|
} catch (AcsApiException $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
|
||||||
|
|
||||||
|
return new ShippingOption(
|
||||||
|
name: $shippingMethod->name ?: $this->name(),
|
||||||
|
description: $shippingMethod->description ?: $this->description(),
|
||||||
|
identifier: $shippingRate->getIdentifier(),
|
||||||
|
price: new Price($amount, $cart->currency, 1),
|
||||||
|
taxClass: $shippingRate->getTaxClass(),
|
||||||
|
taxReference: $shippingRate->getTaxReference(),
|
||||||
|
meta: ['acs_station_destination' => $destination->stationId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function on(ShippingRate $shippingRate): self
|
||||||
|
{
|
||||||
|
$this->shippingRate = $shippingRate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function totalWeightInKg($cart): float
|
||||||
|
{
|
||||||
|
$weight = 0.0;
|
||||||
|
|
||||||
|
foreach ($cart->lines->load('purchasable') as $line) {
|
||||||
|
$variant = $line->purchasable;
|
||||||
|
|
||||||
|
if (! $variant || ! $variant->weight_value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$unit = $variant->weight_unit ?? 'kg';
|
||||||
|
$value = (float) $variant->weight_value;
|
||||||
|
|
||||||
|
$weight += match ($unit) {
|
||||||
|
'g' => $value / 1000,
|
||||||
|
'lb' => $value * 0.45359237,
|
||||||
|
'oz' => $value * 0.0283495231,
|
||||||
|
default => $value, // kg
|
||||||
|
} * $line->quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max($weight, 0.5); // ACS minimum billable weight
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
|
|
||||||
|
class AcsResponse
|
||||||
|
{
|
||||||
|
private function __construct(
|
||||||
|
public readonly bool $hasError,
|
||||||
|
public readonly ?string $errorMessage,
|
||||||
|
public readonly array $valueOutput,
|
||||||
|
public readonly array $tableOutput,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromHttpResponse(Response $response): self
|
||||||
|
{
|
||||||
|
$body = $response->json() ?? [];
|
||||||
|
|
||||||
|
// ACS's own JSON key is misspelled ("Responce") — preserved here verbatim.
|
||||||
|
$output = $body['ACSOutputResponce'] ?? [];
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
hasError: (bool) ($body['ACSExecution_HasError'] ?? ! $response->successful()),
|
||||||
|
errorMessage: $body['ACSExecutionErrorMessage'] ?? null,
|
||||||
|
valueOutput: $output['ACSValueOutput'][0] ?? [],
|
||||||
|
tableOutput: $output['ACSTableOutput'] ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function throwIfError(): self
|
||||||
|
{
|
||||||
|
if ($this->hasError) {
|
||||||
|
throw new AcsApiException(
|
||||||
|
$this->errorMessage ?? ($this->valueOutput['Error_Message'] ?? 'Unknown ACS API error'),
|
||||||
|
$this->tableOutput,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
|
|
||||||
|
class AreaResolver
|
||||||
|
{
|
||||||
|
public const CACHE_KEY = 'acs.areas';
|
||||||
|
|
||||||
|
public function __construct(private readonly AcsClient $client) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a Greek postcode to its ACS station/branch codes.
|
||||||
|
*
|
||||||
|
* Reads from the table warmed daily by WarmAcsAreaCacheJob. Falls
|
||||||
|
* back to a live lookup for that single postcode if the warmed cache is
|
||||||
|
* missing (e.g. the daily job never ran or failed) or doesn't contain it.
|
||||||
|
*/
|
||||||
|
public function resolve(string $postcode): AcsArea
|
||||||
|
{
|
||||||
|
$areas = Cache::get(self::CACHE_KEY);
|
||||||
|
|
||||||
|
if ($areas !== null && isset($areas[$postcode])) {
|
||||||
|
return $this->toArea($areas[$postcode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->toArea($this->fetch($postcode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and cache the full country's postcode-to-station map in one call.
|
||||||
|
*/
|
||||||
|
public function warmAll(): void
|
||||||
|
{
|
||||||
|
$areas = [];
|
||||||
|
|
||||||
|
foreach ($this->fetchAll() as $row) {
|
||||||
|
$areas[$row['Zip_Code']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::forever(self::CACHE_KEY, $areas);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetch(string $postcode): array
|
||||||
|
{
|
||||||
|
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
|
||||||
|
'Zip_Code' => $postcode,
|
||||||
|
'Show_Only_Inaccessible_Areas' => 0,
|
||||||
|
'Country' => 'GR',
|
||||||
|
])->throwIfError();
|
||||||
|
|
||||||
|
$area = $response->tableOutput['Table_Data'][0] ?? null;
|
||||||
|
|
||||||
|
if (! $area) {
|
||||||
|
throw new AcsApiException("No ACS area found for postcode {$postcode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $area;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchAll(): array
|
||||||
|
{
|
||||||
|
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
|
||||||
|
'Zip_Code' => null,
|
||||||
|
'Show_Only_Inaccessible_Areas' => 0,
|
||||||
|
'Country' => 'GR',
|
||||||
|
])->throwIfError();
|
||||||
|
|
||||||
|
return $response->tableOutput['Table_Data'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toArea(array $row): AcsArea
|
||||||
|
{
|
||||||
|
return new AcsArea(
|
||||||
|
stationId: $row['Station_ID'],
|
||||||
|
branchId: (int) $row['Branch_ID'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class AcsApiException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(string $message, public readonly array $tableOutput = [])
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\Acs\Jobs;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\AreaResolver;
|
||||||
|
|
||||||
|
class WarmAcsAreaCacheJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public int $backoff = 60;
|
||||||
|
|
||||||
|
public function handle(AreaResolver $areaResolver): void
|
||||||
|
{
|
||||||
|
$areaResolver->warmAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
|
||||||
|
|
||||||
|
class BoxNowClient
|
||||||
|
{
|
||||||
|
private const TOKEN_CACHE_KEY = 'boxnow.access_token';
|
||||||
|
|
||||||
|
public function __construct(private readonly array $config) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET/POST against the main delivery/parcel API, authenticated with a
|
||||||
|
* cached Bearer token.
|
||||||
|
*/
|
||||||
|
public function request(string $method, string $path, array $payload = []): array
|
||||||
|
{
|
||||||
|
$response = Http::withToken($this->token())
|
||||||
|
->timeout($this->config['timeout'])
|
||||||
|
->{$method}("{$this->config['base_url']}{$path}", $payload);
|
||||||
|
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
// Token expired early / was revoked — refresh once and retry.
|
||||||
|
Cache::forget(self::TOKEN_CACHE_KEY);
|
||||||
|
|
||||||
|
$response = Http::withToken($this->token())
|
||||||
|
->timeout($this->config['timeout'])
|
||||||
|
->{$method}("{$this->config['base_url']}{$path}", $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new BoxNowApiException(
|
||||||
|
"Box Now API error ({$response->status()}) on {$method} {$path}",
|
||||||
|
$response->json() ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The origins/destinations lookups are served faster from a separate
|
||||||
|
* location API host, per Box Now's own documentation.
|
||||||
|
*/
|
||||||
|
public function locationRequest(string $path, array $query = []): array
|
||||||
|
{
|
||||||
|
$response = Http::withToken($this->token())
|
||||||
|
->timeout($this->config['timeout'])
|
||||||
|
->get("{$this->config['location_api_url']}{$path}", $query);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new BoxNowApiException(
|
||||||
|
"Box Now location API error ({$response->status()}) on GET {$path}",
|
||||||
|
$response->json() ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch raw bytes (e.g. a PDF label) rather than JSON.
|
||||||
|
*/
|
||||||
|
public function requestRaw(string $path): string
|
||||||
|
{
|
||||||
|
$response = Http::withToken($this->token())
|
||||||
|
->timeout($this->config['timeout'])
|
||||||
|
->get("{$this->config['base_url']}{$path}");
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new BoxNowApiException("Box Now API error ({$response->status()}) on GET {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->body();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(): string
|
||||||
|
{
|
||||||
|
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(55), function () {
|
||||||
|
$response = Http::timeout($this->config['timeout'])
|
||||||
|
->post("{$this->config['base_url']}/auth-sessions", [
|
||||||
|
'grant_type' => 'client_credentials',
|
||||||
|
'client_id' => $this->config['client_id'],
|
||||||
|
'client_secret' => $this->config['client_secret'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new BoxNowApiException('Box Now authentication failed', $response->json() ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json('access_token');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
|
||||||
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlike ACS, Box Now has no manifest/pickup-list step — creating a
|
||||||
|
* delivery request also books the courier pickup, so this only implements
|
||||||
|
* CarrierFulfillmentInterface (not SupportsManifestBatching).
|
||||||
|
*
|
||||||
|
* Box Now delivers to lockers, not addresses. The storefront locker-picker
|
||||||
|
* is out of scope for this pass — createShipment() expects the chosen
|
||||||
|
* locker's Box Now locationId via $overrides['locationId'] (e.g. set
|
||||||
|
* manually by admin staff until checkout UI exists).
|
||||||
|
*/
|
||||||
|
class BoxNowFulfillmentService implements CarrierFulfillmentInterface
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BoxNowClient $client) {}
|
||||||
|
|
||||||
|
public function createShipment(Order $order, array $overrides = []): Shipment
|
||||||
|
{
|
||||||
|
$address = $order->shippingAddress;
|
||||||
|
$destinationLocationId = $overrides['locationId'] ?? null;
|
||||||
|
|
||||||
|
if (! $destinationLocationId) {
|
||||||
|
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->client->request('post', '/delivery-requests', [
|
||||||
|
'orderNumber' => $order->reference.'-'.$order->id,
|
||||||
|
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
|
||||||
|
'paymentMode' => 'prepaid',
|
||||||
|
'amountToBeCollected' => '0.00',
|
||||||
|
'origin' => [
|
||||||
|
'contactNumber' => config('boxnow.sender.phone'),
|
||||||
|
'contactEmail' => config('boxnow.sender.email'),
|
||||||
|
'contactName' => config('boxnow.sender.name'),
|
||||||
|
'locationId' => config('boxnow.origin_location_id'),
|
||||||
|
],
|
||||||
|
'destination' => [
|
||||||
|
'contactNumber' => $address->contact_phone,
|
||||||
|
'contactEmail' => $address->contact_email,
|
||||||
|
'contactName' => trim("{$address->first_name} {$address->last_name}"),
|
||||||
|
'locationId' => $destinationLocationId,
|
||||||
|
],
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'id' => (string) $order->id,
|
||||||
|
'name' => 'Order '.$order->reference,
|
||||||
|
'value' => '0.00',
|
||||||
|
'compartmentSize' => $overrides['compartmentSize'] ?? 1,
|
||||||
|
'weight' => $overrides['weight'] ?? 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
|
||||||
|
'Box Now delivery request succeeded but returned no parcel id.',
|
||||||
|
$response,
|
||||||
|
));
|
||||||
|
|
||||||
|
return Shipment::create([
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'carrier' => 'box-now',
|
||||||
|
'tracking_reference' => $parcelId,
|
||||||
|
'meta' => [
|
||||||
|
'delivery_request_id' => $response['id'] ?? null,
|
||||||
|
'locker_id' => $destinationLocationId,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function printLabel(Shipment $shipment): string
|
||||||
|
{
|
||||||
|
$bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf");
|
||||||
|
|
||||||
|
$shipment->update(['label_printed_at' => now()]);
|
||||||
|
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelShipment(Shipment $shipment): void
|
||||||
|
{
|
||||||
|
$this->client->request('post', "/parcels/{$shipment->tracking_reference}:cancel");
|
||||||
|
|
||||||
|
$shipment->update(['cancelled_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\ShippingOption;
|
||||||
|
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||||
|
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||||
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Box Now has no pricing API, so this always resolves the method's normal
|
||||||
|
* charge_by + price-break configuration — the same mechanism the built-in
|
||||||
|
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
|
||||||
|
* there is no live option to offer.
|
||||||
|
*/
|
||||||
|
class BoxNowRateDriver implements ShippingRateInterface
|
||||||
|
{
|
||||||
|
use ResolvesFixedPricing;
|
||||||
|
|
||||||
|
public ShippingRate $shippingRate;
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'Box Now Locker Delivery';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Deliver to a Box Now parcel locker.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
|
||||||
|
{
|
||||||
|
return $this->resolveFixedPrice(
|
||||||
|
$shippingOptionRequest->shippingRate,
|
||||||
|
$shippingOptionRequest->shippingRate->shippingMethod,
|
||||||
|
$shippingOptionRequest->cart,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function on(ShippingRate $shippingRate): self
|
||||||
|
{
|
||||||
|
$this->shippingRate = $shippingRate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\BoxNow\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class BoxNowApiException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(string $message, public readonly array $body = [])
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Concerns;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\ShippingOption;
|
||||||
|
use Lunar\Facades\Pricing;
|
||||||
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared by any carrier driver that also supports Lunar's own price-break
|
||||||
|
* pricing (charge_by = cart_total | weight) as a fallback to, or standalone
|
||||||
|
* alternative for, live API pricing. Mirrors the vendor ShipBy driver's
|
||||||
|
* charge_by handling exactly, so behavior is consistent with the rest of
|
||||||
|
* Lunar's shipping system rather than inventing a separate mechanism.
|
||||||
|
*/
|
||||||
|
trait ResolvesFixedPricing
|
||||||
|
{
|
||||||
|
private function resolveFixedPrice(ShippingRate $shippingRate, ShippingMethod $shippingMethod, $cart): ?ShippingOption
|
||||||
|
{
|
||||||
|
$chargeBy = $shippingMethod->data['charge_by'] ?? 'cart_total';
|
||||||
|
|
||||||
|
$tier = $chargeBy === 'weight'
|
||||||
|
? $cart->lines->load('purchasable')->sum(fn ($line) => ($line->purchasable->weight_value ?? 0) * $line->quantity)
|
||||||
|
: $cart->lines->sum('subTotal.value');
|
||||||
|
|
||||||
|
$pricing = Pricing::for($shippingRate)->qty($tier)->get();
|
||||||
|
|
||||||
|
if (! $pricing->matched) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ShippingOption(
|
||||||
|
name: $shippingMethod->name ?: $this->name(),
|
||||||
|
description: $shippingMethod->description ?: $this->description(),
|
||||||
|
identifier: $shippingRate->getIdentifier(),
|
||||||
|
price: $pricing->matched->price,
|
||||||
|
taxClass: $shippingRate->getTaxClass(),
|
||||||
|
taxReference: $shippingRate->getTaxReference(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
|
interface CarrierFulfillmentInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a shipment with the carrier for the given order.
|
||||||
|
*/
|
||||||
|
public function createShipment(Order $order, array $overrides = []): Shipment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the printable label for a shipment (raw file bytes).
|
||||||
|
*/
|
||||||
|
public function printLabel(Shipment $shipment): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a shipment with the carrier.
|
||||||
|
*/
|
||||||
|
public function cancelShipment(Shipment $shipment): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker for a Lunar\Shipping\Interfaces\ShippingRateInterface driver that
|
||||||
|
* calculates its price from a live carrier API rather than the manually
|
||||||
|
* configured ShippingRate price/price-breaks. Admin UI uses this to hide
|
||||||
|
* the manual pricing fields for such drivers — carriers without a pricing
|
||||||
|
* API simply don't implement it, and manual pricing remains required.
|
||||||
|
*/
|
||||||
|
interface SupportsLivePricing {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional capability for carriers that batch shipments into a manifest
|
||||||
|
* before courier pickup (e.g. ACS's end-of-day pickup list). Carriers
|
||||||
|
* without this concept simply don't implement it.
|
||||||
|
*/
|
||||||
|
interface SupportsManifestBatching
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Shipments created but not yet included in an issued manifest.
|
||||||
|
*/
|
||||||
|
public function pendingForManifest(): Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize the given shipments into a manifest with the carrier.
|
||||||
|
*/
|
||||||
|
public function issueManifest(Collection $shipments): ManifestResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\DataTransferObjects;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class ManifestResult
|
||||||
|
{
|
||||||
|
private function __construct(
|
||||||
|
public readonly bool $success,
|
||||||
|
public readonly ?string $reference,
|
||||||
|
public readonly Collection $includedShipments,
|
||||||
|
public readonly Collection $blockedShipments,
|
||||||
|
public readonly ?string $reason,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function success(string $reference, Collection $includedShipments): self
|
||||||
|
{
|
||||||
|
return new self(true, $reference, $includedShipments, collect(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function blocked(Collection $blockedShipments, string $reason): self
|
||||||
|
{
|
||||||
|
return new self(false, null, collect(), $blockedShipments, $reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Extensions;
|
||||||
|
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Lunar\Admin\Support\Extending\ViewPageExtension;
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
|
|
||||||
|
class OrderViewExtension extends ViewPageExtension
|
||||||
|
{
|
||||||
|
public function headerActions(array $actions): array
|
||||||
|
{
|
||||||
|
$actions[] = $this->createShipmentAction();
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createShipmentAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return Actions\Action::make('create_shipment')
|
||||||
|
->label('Create Shipment')
|
||||||
|
->icon('heroicon-o-truck')
|
||||||
|
->modalSubmitActionLabel('Create Shipment')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Toggle::make('confirm')
|
||||||
|
->label('Confirm')
|
||||||
|
->helperText('This will create a real shipment with the carrier.')
|
||||||
|
->rules([
|
||||||
|
function () {
|
||||||
|
return function (string $attribute, $value, \Closure $fail) {
|
||||||
|
if ($value !== true) {
|
||||||
|
$fail('Please confirm before creating the shipment.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(function (Order $record, Actions\Action $action) {
|
||||||
|
$service = $this->resolveFulfillmentService($record);
|
||||||
|
|
||||||
|
if (! $service) {
|
||||||
|
Notification::make()
|
||||||
|
->title('No carrier fulfillment integration is configured for this order.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$action->halt();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->createShipment($record);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Failed to create shipment: '.$e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$action->halt();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Shipment created.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->visible(fn (Order $record) => $record->shipments()->exists() === false
|
||||||
|
&& $this->resolveFulfillmentService($record) !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCarrier(Order $record): ?string
|
||||||
|
{
|
||||||
|
$code = $record->shippingAddress?->shipping_option;
|
||||||
|
|
||||||
|
if (! $code) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ShippingMethod::where('code', $code)->value('driver');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface
|
||||||
|
{
|
||||||
|
$carrier = $this->resolveCarrier($record);
|
||||||
|
|
||||||
|
if (! $carrier) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Extensions;
|
||||||
|
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\Group;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Lunar\Admin\Support\Extending\BaseExtension;
|
||||||
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
|
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's
|
||||||
|
* form inline (calling ShippingMethodResource::getDriverFormComponent()
|
||||||
|
* directly, a hardcoded 2-option Select) rather than through the resource's
|
||||||
|
* own extendForm() pipeline, so ShippingMethodResourceExtension's driver
|
||||||
|
* fix never reaches it. Re-declares the same create-action form with a
|
||||||
|
* dynamic driver Select instead.
|
||||||
|
*/
|
||||||
|
class ShippingMethodListExtension extends BaseExtension
|
||||||
|
{
|
||||||
|
public function headerActions(array $actions): array
|
||||||
|
{
|
||||||
|
foreach ($actions as $action) {
|
||||||
|
if ($action instanceof Actions\CreateAction) {
|
||||||
|
$action->form([
|
||||||
|
ShippingMethodResource::getNameFormComponent(),
|
||||||
|
Group::make([
|
||||||
|
ShippingMethodResource::getCodeFormComponent(),
|
||||||
|
$this->driverSelect(),
|
||||||
|
])->columns(2),
|
||||||
|
ShippingMethodResource::getDescriptionFormComponent(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function driverSelect(): Select
|
||||||
|
{
|
||||||
|
return Select::make('driver')
|
||||||
|
->label('Type')
|
||||||
|
->options(fn () => collect(Shipping::getSupportedDrivers())
|
||||||
|
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
|
||||||
|
->default('flat-rate');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Extensions;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\Component;
|
||||||
|
use Filament\Forms\Components\Concerns\HasChildComponents;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Forms\Get;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||||
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||||
|
|
||||||
|
class ShippingMethodResourceExtension extends ResourceExtension
|
||||||
|
{
|
||||||
|
public function extendForm(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form->schema(
|
||||||
|
$this->replaceChargeByField(
|
||||||
|
$this->replaceDriverField($form->getComponents())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend the vendor's cart_total/weight charge_by Select with a third
|
||||||
|
* "live" option — only offered when the currently selected driver
|
||||||
|
* supports live pricing (see SupportsLivePricing). Picking it is what
|
||||||
|
* tells the driver to call its carrier API instead of resolving a
|
||||||
|
* price break.
|
||||||
|
*/
|
||||||
|
private function replaceChargeByField(array $components): array
|
||||||
|
{
|
||||||
|
return array_map(function (Component $component) {
|
||||||
|
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
|
||||||
|
return $this->chargeBySelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
|
||||||
|
$component->schema(
|
||||||
|
$this->replaceChargeByField($component->getChildComponents())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $component;
|
||||||
|
}, $components);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function chargeBySelect(): Select
|
||||||
|
{
|
||||||
|
return Select::make('charge_by')
|
||||||
|
->label('Charge by')
|
||||||
|
->options(function (Get $get) {
|
||||||
|
$options = [
|
||||||
|
'cart_total' => 'Cart Total',
|
||||||
|
'weight' => 'Weight',
|
||||||
|
];
|
||||||
|
|
||||||
|
// "charge_by" is nested inside a Group with
|
||||||
|
// ->statePath('data'), while "driver" sits one level up, at
|
||||||
|
// the form root. Note: an *absolute* path here would need to
|
||||||
|
// additionally account for the page's own form wrapper
|
||||||
|
// (EditRecord::getFormStatePath() === 'data'), which relative
|
||||||
|
// paths never cross — so "../driver" (relative) is the
|
||||||
|
// correct, page-independent way to reach it, not an
|
||||||
|
// absolute 'driver' string.
|
||||||
|
if ($this->driverSupportsLivePricing($get('../driver'))) {
|
||||||
|
$options['live'] = 'Live API pricing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
})
|
||||||
|
->live();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function driverSupportsLivePricing(?string $driver): bool
|
||||||
|
{
|
||||||
|
if (! $driver) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Shipping::driver($driver) instanceof SupportsLivePricing;
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extendTable(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table->columns(
|
||||||
|
array_map(function ($column) {
|
||||||
|
if (method_exists($column, 'getName') && $column->getName() === 'driver') {
|
||||||
|
return $this->driverColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $column;
|
||||||
|
}, $table->getColumns())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function driverColumn(): TextColumn
|
||||||
|
{
|
||||||
|
return TextColumn::make('driver')
|
||||||
|
->label('Type')
|
||||||
|
->formatStateUsing(fn ($state) => $this->driverLabel($state));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function driverLabel(string $key): string
|
||||||
|
{
|
||||||
|
$driver = collect(Shipping::getSupportedDrivers())->get($key);
|
||||||
|
|
||||||
|
return $driver?->name() ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walk the form tree and replace the hardcoded driver
|
||||||
|
* Select (nested inside Section > Group) with one listing every
|
||||||
|
* registered driver, built-in or custom.
|
||||||
|
*
|
||||||
|
* @param array<Component> $components
|
||||||
|
* @return array<Component>
|
||||||
|
*/
|
||||||
|
private function replaceDriverField(array $components): array
|
||||||
|
{
|
||||||
|
return array_map(function (Component $component) {
|
||||||
|
if (method_exists($component, 'getName') && $component->getName() === 'driver') {
|
||||||
|
return $this->driverSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
|
||||||
|
$component->schema(
|
||||||
|
$this->replaceDriverField($component->getChildComponents())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $component;
|
||||||
|
}, $components);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function driverSelect(): Select
|
||||||
|
{
|
||||||
|
return Select::make('driver')
|
||||||
|
->label('Type')
|
||||||
|
->options(fn () => collect(Shipping::getSupportedDrivers())
|
||||||
|
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
|
||||||
|
->default('flat-rate')
|
||||||
|
->live();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Filament\Pages;
|
||||||
|
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Actions\BulkAction;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||||
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
|
class ManagePickupManifests extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-truck';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Pickup Manifests';
|
||||||
|
|
||||||
|
protected static string $view = 'core::shipping.filament.pages.manage-pickup-manifests';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query($this->pendingQuery())
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('carrier')->badge(),
|
||||||
|
TextColumn::make('tracking_reference')->label('Tracking #'),
|
||||||
|
TextColumn::make('order.reference')->label('Order'),
|
||||||
|
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('print')
|
||||||
|
->label('Print')
|
||||||
|
->icon('heroicon-o-printer')
|
||||||
|
->action(fn (Shipment $record) => $this->printShipment($record)),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkAction::make('print_selected')
|
||||||
|
->label('Print selected')
|
||||||
|
->icon('heroicon-o-printer')
|
||||||
|
->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => $this->printShipment($shipment))),
|
||||||
|
BulkAction::make('issue_manifest')
|
||||||
|
->label('Issue Manifest')
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->action(fn (Collection $records) => $this->issueManifest($records)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pendingQuery(): Builder
|
||||||
|
{
|
||||||
|
$carriers = collect(Shipping::getSupportedDrivers())->keys()->filter(
|
||||||
|
fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsManifestBatching
|
||||||
|
);
|
||||||
|
|
||||||
|
return Shipment::query()
|
||||||
|
->whereIn('carrier', $carriers)
|
||||||
|
->whereNull('manifest_reference')
|
||||||
|
->whereNull('cancelled_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printShipment(Shipment $shipment): void
|
||||||
|
{
|
||||||
|
$this->fulfillmentService($shipment->carrier)?->printLabel($shipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issueManifest(Collection $shipments): void
|
||||||
|
{
|
||||||
|
$shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
|
||||||
|
$service = $this->fulfillmentService($carrier);
|
||||||
|
|
||||||
|
if (! $service instanceof SupportsManifestBatching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $service->issueManifest($group);
|
||||||
|
|
||||||
|
if (! $result->success) {
|
||||||
|
Notification::make()
|
||||||
|
->title("Manifest blocked for {$carrier}: {$result->reason}")
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title("Manifest issued for {$carrier}: {$result->reference}")
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
|
||||||
|
{
|
||||||
|
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Filament\Pages;
|
||||||
|
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Forms\Get;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
|
||||||
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bound in place of the vendor ManageShippingRates page via the container
|
||||||
|
* (see ShippingServiceProvider), since that page has no extension hook of
|
||||||
|
* its own. Every reference to the vendor class name — routes, sub-nav,
|
||||||
|
* ShippingZoneResource::getPages() — is untouched; the container simply
|
||||||
|
* hands back this subclass whenever the vendor class is resolved.
|
||||||
|
*
|
||||||
|
* Hides the price / price-break fields for a rate whose method has
|
||||||
|
* charge_by = "live" (see ShippingMethodResourceExtension, which adds that
|
||||||
|
* option to methods whose driver supports live pricing) — those fields
|
||||||
|
* would otherwise be dead configuration the driver never reads. Pricing
|
||||||
|
* strategy (cart_total / weight / live) stays entirely on the Shipping
|
||||||
|
* Method, matching Lunar's own existing charge_by convention; nothing new
|
||||||
|
* is stored on the rate itself.
|
||||||
|
*/
|
||||||
|
class ManageShippingRates extends BaseManageShippingRates
|
||||||
|
{
|
||||||
|
public function form(Form $form): Form
|
||||||
|
{
|
||||||
|
$form = parent::form($form);
|
||||||
|
|
||||||
|
return $form->schema(
|
||||||
|
$this->hidePriceFieldsWhenLive($form->getComponents())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hidePriceFieldsWhenLive(array $components): array
|
||||||
|
{
|
||||||
|
$isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live';
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
if (! method_exists($component, 'getName')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($component->getName() === 'price') {
|
||||||
|
$component->visible($isNotLive)->required($isNotLive)->dehydrated(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($component->getName() === 'prices') {
|
||||||
|
$component->visible($isNotLive)->dehydrated(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $components;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
$table = parent::table($table);
|
||||||
|
|
||||||
|
return $table->columns(
|
||||||
|
array_map(function ($column) {
|
||||||
|
if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') {
|
||||||
|
return TextColumn::make('basePrices.0')
|
||||||
|
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
|
||||||
|
->formatStateUsing(function ($state, ShippingRate $record) {
|
||||||
|
if (static::methodChargeBy($record->shipping_method_id) === 'live') {
|
||||||
|
return 'Live API pricing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $state?->price->formatted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $column;
|
||||||
|
}, $table->getColumns())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
|
||||||
|
{
|
||||||
|
if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::saveShippingRate($shippingRate, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string
|
||||||
|
{
|
||||||
|
if (blank($method)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $method instanceof ShippingMethod) {
|
||||||
|
$method = ShippingMethod::find($method);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $method?->data['charge_by'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
|
||||||
|
class Shipment extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'meta' => AsArrayObject::class,
|
||||||
|
'label_printed_at' => 'datetime',
|
||||||
|
'cancelled_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function order(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Order::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user