Feature: Order Updates, Events, Order Flows, Shipment And COD support
This commit is contained in:
@@ -48,4 +48,21 @@ return [
|
||||
'unrecoverable_after' => '90 days',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Order Return Window
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| How many days after a carrier order is delivered (Order::fulfillment_status
|
||||
| becomes 'return_window_open') before Modules\Core\Order\Commands\
|
||||
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
|
||||
| Store-pickup orders have no return-window step and are unaffected by
|
||||
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
||||
|
|
||||
*/
|
||||
|
||||
'order' => [
|
||||
'return_window_days' => 14,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Splits Lunar's single flat `status` column into three independently
|
||||
* tracked axes — payment, fulfillment, return — so a payment refund and a
|
||||
* fulfillment dispatch stop racing to write the same field, and each axis
|
||||
* can be filtered/queried directly instead of overloading one string for
|
||||
* three unrelated concerns. See Modules\Core\Order\Enums\OrderPaymentStatus/
|
||||
* OrderFulfillmentStatus/OrderReturnStatus for the value vocabularies, and
|
||||
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus and friends for
|
||||
* where these columns actually get written. `status` itself is left in
|
||||
* place, unchanged — Lunar core still reads/writes it in places this
|
||||
* package doesn't own — but nothing in this package's business logic keys
|
||||
* off it anymore after this migration's consumers land.
|
||||
*
|
||||
* lunar_customers already has a direct precedent for a boboko-core
|
||||
* migration altering a Lunar-owned table (see
|
||||
* 2026_07_02_000002_drop_otp_from_lunar_customers_table.php) — this is not
|
||||
* a new pattern for this codebase, just the first time it's applied to
|
||||
* lunar_orders.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('lunar_orders', function (Blueprint $table) {
|
||||
$table->string('payment_status')->default('awaiting_payment')->after('status')->index();
|
||||
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
|
||||
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('lunar_orders', function (Blueprint $table) {
|
||||
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Append-only audit trail for Order's three status axes (see
|
||||
* 2026_09_11_000001_add_status_axes_to_orders_table.php) — the thing
|
||||
* `Lunar\Models\Order::getDefaultLogExcept()` explicitly denies (`status`
|
||||
* is excluded from Lunar's own Spatie activity log), so this is a
|
||||
* from-scratch mechanism, not a gap in an existing one.
|
||||
*
|
||||
* No `updated_at` — a row is never edited after it's written, only ever
|
||||
* inserted. `event_class` is the FQCN of whatever business event/action
|
||||
* caused the write (e.g. Modules\Core\Order\Events\OrderDispatched, or a
|
||||
* plain string like 'Modules\Core\Shipping\Extensions\OrderViewExtension::
|
||||
* markDispatchedAction' for a manual Filament action that has no backing
|
||||
* event class of its own) — see Modules\Core\Order\Services\
|
||||
* OrderStatusTransitionRecorder.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('order_status_transitions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('order_id')->constrained('lunar_orders')->cascadeOnDelete();
|
||||
$table->string('axis');
|
||||
$table->string('from_status')->nullable();
|
||||
$table->string('to_status');
|
||||
$table->string('event_class');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->index(['order_id', 'axis']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('order_status_transitions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Enums\PaymentStatus;
|
||||
use Modules\Core\Order\Support\OrderStatus;
|
||||
|
||||
/**
|
||||
* Maps every existing order's flat `status` (as it stood before
|
||||
* 2026_09_11_000001_add_status_axes_to_orders_table.php) onto the new
|
||||
* payment_status/fulfillment_status/return_status columns. A separate
|
||||
* migration from the schema change so the schema migration stays simply
|
||||
* reversible via down(), and this data pass can be independently re-run.
|
||||
*
|
||||
* The flat status never captured refunds at all (no 'refunded' value was
|
||||
* ever added to config('lunar.orders.statuses')), so the table-driven
|
||||
* mapping below is corrected per-order by re-deriving
|
||||
* Modules\Core\Order\Support\OrderStatus::payment() — the existing,
|
||||
* unchanged derived-enum logic — and overriding payment_status to
|
||||
* refunded/partially_refunded wherever it disagrees with the flat-status
|
||||
* mapping. This is the one place the "keep the old derived enums" design
|
||||
* decision earns its keep: refund-fraction math isn't reimplemented here,
|
||||
* just reused.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private const MAP = [
|
||||
'awaiting-payment' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
|
||||
'payment-offline' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
|
||||
'payment-received' => ['payment_status' => 'paid', 'fulfillment_status' => 'unfulfilled'],
|
||||
'ready-for-dispatch' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
|
||||
'ready-for-pickup' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
|
||||
'dispatched' => ['payment_status' => 'paid', 'fulfillment_status' => 'in_transit'],
|
||||
'completed' => ['payment_status' => 'paid', 'fulfillment_status' => 'completed'],
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
Order::query()->with('transactions')->chunkById(200, function ($orders) {
|
||||
foreach ($orders as $order) {
|
||||
$mapped = self::MAP[$order->status] ?? null;
|
||||
|
||||
if ($mapped === null) {
|
||||
Log::warning('Order status axis backfill: unmapped status, leaving column defaults', [
|
||||
'order_id' => $order->id,
|
||||
'status' => $order->status,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$paymentStatus = $mapped['payment_status'];
|
||||
|
||||
$derived = OrderStatus::payment($order);
|
||||
|
||||
if ($derived === PaymentStatus::Refunded) {
|
||||
$paymentStatus = 'refunded';
|
||||
} elseif ($derived === PaymentStatus::PartialRefund) {
|
||||
$paymentStatus = 'partially_refunded';
|
||||
}
|
||||
|
||||
DB::table('lunar_orders')->where('id', $order->id)->update([
|
||||
'payment_status' => $paymentStatus,
|
||||
'fulfillment_status' => $mapped['fulfillment_status'],
|
||||
'return_status' => 'none',
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Column defaults (set in the schema migration) are the correct
|
||||
// "undo" — no need to reverse-map back to the flat status, since
|
||||
// `status` itself was never touched by this migration.
|
||||
}
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* captured_status/authorized_status/refunded_status let a merchant pick
|
||||
* which per-method Order::status label a payment outcome resulted in — a
|
||||
* mechanism that only made sense while Order.status was the single field
|
||||
* carrying that meaning. Modules\Core\Order\Listeners\
|
||||
* ApplyResolvedPaymentStatus now writes a fixed 3-value payment_status
|
||||
* column instead (see 2026_09_11_000001_add_status_axes_to_orders_table.php);
|
||||
* there is no longer any per-method flexibility to preserve — "paid" is
|
||||
* "paid" regardless of which method captured it. Dropped rather than left
|
||||
* vestigial: keeping them visible in the admin would let a merchant
|
||||
* configure something that silently does nothing.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('payment_methods', function (Blueprint $table) {
|
||||
$table->dropColumn(['captured_status', 'authorized_status', 'refunded_status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('payment_methods', function (Blueprint $table) {
|
||||
$table->string('captured_status')->nullable();
|
||||
$table->string('authorized_status')->nullable();
|
||||
$table->string('refunded_status')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Order::paid/paid_at — entirely independent of the `status` column (see
|
||||
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
|
||||
* payment timing, especially for cash-on-delivery, cannot be modeled as a
|
||||
* status-sequence step). `paid` is the fast-filter boolean; `paid_at` is
|
||||
* when it actually happened.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('lunar_orders', function (Blueprint $table) {
|
||||
$table->boolean('paid')->default(false)->after('status')->index();
|
||||
$table->timestamp('paid_at')->nullable()->after('paid');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('lunar_orders', function (Blueprint $table) {
|
||||
$table->dropColumn(['paid', 'paid_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Enums\PaymentStatus;
|
||||
use Modules\Core\Order\Support\OrderStatus;
|
||||
|
||||
/**
|
||||
* Collapses the 3-axis (payment_status/fulfillment_status/return_status)
|
||||
* model this session briefly built — abandoned before shipping — back
|
||||
* onto a single `status` column plus the new independent `paid`/`paid_at`
|
||||
* fields. Must run after 2026_09_12_000001 (adds paid/paid_at) and before
|
||||
* 2026_09_12_000003 (drops the axis columns this migration still reads).
|
||||
*
|
||||
* Priority rule: axis data where it's genuinely non-default (this order
|
||||
* was really moved through the axis system during this session's manual
|
||||
* testing); the legacy `status` column (which may still hold pre-session
|
||||
* hyphenated values) as fallback everywhere else.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private const LEGACY_MAP = [
|
||||
'awaiting-payment' => 'awaiting_payment',
|
||||
'payment-offline' => 'awaiting_payment',
|
||||
'payment-received' => 'processing',
|
||||
'ready-for-dispatch' => 'ready_for_dispatch',
|
||||
'ready-for-pickup' => 'ready_for_pickup',
|
||||
'dispatched' => 'dispatched',
|
||||
'completed' => 'completed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Axis fulfillment_status -> new single status, given branch. Axis
|
||||
* 'delivered' folds into 'return_window_open' (same combined-value
|
||||
* decision the going-forward design makes). Axis payment_status is
|
||||
* used only to decide whether a fully-unfulfilled order should read
|
||||
* as 'awaiting_payment' or 'processing'.
|
||||
*/
|
||||
private function mapFromAxes(string $payment, string $fulfillment, string $return, bool $isPickup): ?string
|
||||
{
|
||||
if ($return === 'returned') {
|
||||
return 'returned';
|
||||
}
|
||||
if ($return === 'requested') {
|
||||
return 'return_requested';
|
||||
}
|
||||
|
||||
return match ($fulfillment) {
|
||||
'unfulfilled' => $payment === 'paid' ? 'processing' : 'awaiting_payment',
|
||||
'processing' => 'processing',
|
||||
'ready' => $isPickup ? 'ready_for_pickup' : 'ready_for_dispatch',
|
||||
'in_transit' => 'dispatched',
|
||||
'delivered', 'return_window_open' => 'return_window_open',
|
||||
'picked_up' => 'picked_up',
|
||||
'completed' => 'completed',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
Order::query()->with('transactions')->chunkById(200, function ($orders) {
|
||||
foreach ($orders as $order) {
|
||||
$isPickup = $order->isStorePickupOrder();
|
||||
|
||||
$axisIsDefault = $order->payment_status === 'awaiting_payment'
|
||||
&& $order->fulfillment_status === 'unfulfilled'
|
||||
&& $order->return_status === 'none';
|
||||
|
||||
$status = $axisIsDefault
|
||||
? (self::LEGACY_MAP[$order->status] ?? null)
|
||||
: $this->mapFromAxes($order->payment_status, $order->fulfillment_status, $order->return_status, $isPickup);
|
||||
|
||||
if ($status === null) {
|
||||
Log::warning('Single-status backfill: unmapped order, defaulting to awaiting_payment', [
|
||||
'order_id' => $order->id,
|
||||
'status' => $order->status,
|
||||
'payment_status' => $order->payment_status,
|
||||
'fulfillment_status' => $order->fulfillment_status,
|
||||
'return_status' => $order->return_status,
|
||||
]);
|
||||
$status = 'awaiting_payment';
|
||||
}
|
||||
|
||||
$derived = OrderStatus::payment($order);
|
||||
$paid = $order->payment_status === 'paid'
|
||||
|| in_array($derived, [PaymentStatus::Captured, PaymentStatus::Refunded, PaymentStatus::PartialRefund], true);
|
||||
|
||||
// A refund implies the order concluded via a return —
|
||||
// even one backfilled to an early status (e.g. an order
|
||||
// refunded before fulfillment ever started) is corrected
|
||||
// to refunded/partially_refunded here, not left stuck
|
||||
// pre-fulfillment with no sign a refund ever happened.
|
||||
if ($derived === PaymentStatus::Refunded) {
|
||||
$status = 'refunded';
|
||||
} elseif ($derived === PaymentStatus::PartialRefund) {
|
||||
$status = 'partially_refunded';
|
||||
}
|
||||
|
||||
DB::table('lunar_orders')->where('id', $order->id)->update([
|
||||
'status' => $status,
|
||||
'paid' => $paid,
|
||||
'paid_at' => $paid ? ($order->placed_at ?? now()) : null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// No reverse mapping — column defaults (post-rollback of the
|
||||
// schema migrations) are the correct "undo".
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Reverses 2026_09_11_000001_add_status_axes_to_orders_table.php — the
|
||||
* 3-axis model was abandoned before shipping in favor of a single
|
||||
* `status` column plus independent `paid`/`paid_at` (see
|
||||
* 2026_09_12_000001/000002). Must run after 2026_09_12_000002, which
|
||||
* still reads these columns for the backfill.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('lunar_orders', function (Blueprint $table) {
|
||||
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Mirrors 2026_09_11_000001's own down() — restores columns
|
||||
// empty/defaulted, does not attempt to resurrect real per-order
|
||||
// values.
|
||||
Schema::table('lunar_orders', function (Blueprint $table) {
|
||||
$table->string('payment_status')->default('awaiting_payment')->after('paid_at')->index();
|
||||
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
|
||||
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
|
||||
});
|
||||
}
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* There is only one status column left to audit (plus the synthetic
|
||||
* 'paid' entry — see Modules\Core\Order\Listeners\RecordStatusTransition),
|
||||
* so the `axis` column this table was created with
|
||||
* (2026_09_11_000002_create_order_status_transitions_table.php) no longer
|
||||
* means anything.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('order_status_transitions', function (Blueprint $table) {
|
||||
$table->dropIndex(['order_id', 'axis']);
|
||||
$table->dropColumn('axis');
|
||||
$table->index('order_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('order_status_transitions', function (Blueprint $table) {
|
||||
$table->dropIndex(['order_id']);
|
||||
$table->string('axis')->default('status')->after('order_id');
|
||||
$table->index(['order_id', 'axis']);
|
||||
});
|
||||
}
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* The seeded 'cash-on-delivery' PaymentMethod row
|
||||
* (Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()) was
|
||||
* wired to driver => 'offline' — the same immediate-capture driver as
|
||||
* cash-in-hand. That's the bug that made COD "pay immediately" instead of
|
||||
* waiting for staff to confirm cash was actually received. Repoints
|
||||
* already-seeded environments to the new dedicated
|
||||
* Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver; the seeder
|
||||
* itself is fixed separately for fresh installs.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'cash-on-delivery']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'offline']);
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 'return_window_open' is renamed to 'delivered' — same status value,
|
||||
* same meaning (the parcel arrived AND the return window is now open,
|
||||
* still one combined moment — see Modules\Core\Order\Listeners\
|
||||
* AdvanceFulfillmentOnDelivered), just a name a merchant expects to read
|
||||
* on the order page rather than an internal mechanic. Also renames it in
|
||||
* order_status_transitions' audit rows so the history stays consistent
|
||||
* with `status` going forward.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('lunar_orders')->where('status', 'return_window_open')->update(['status' => 'delivered']);
|
||||
DB::table('order_status_transitions')->where('from_status', 'return_window_open')->update(['from_status' => 'delivered']);
|
||||
DB::table('order_status_transitions')->where('to_status', 'return_window_open')->update(['to_status' => 'delivered']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('lunar_orders')->where('status', 'delivered')->update(['status' => 'return_window_open']);
|
||||
DB::table('order_status_transitions')->where('from_status', 'delivered')->update(['from_status' => 'return_window_open']);
|
||||
DB::table('order_status_transitions')->where('to_status', 'delivered')->update(['to_status' => 'return_window_open']);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* A real record of "a manifest was issued", not just a loose
|
||||
* manifest_reference string stamped onto each Shipment row — ACS's own
|
||||
* ACS_Issue_Pickup_List call returns nothing beyond a PickupList_No (see
|
||||
* Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService::issueManifest()),
|
||||
* so this table is entirely our own bookkeeping: when the manifest was
|
||||
* issued and how many shipments it included, not something re-derivable
|
||||
* from the carrier later. `shipment_count` is denormalized (also
|
||||
* countable via shipments()->count()) purely so the manifests list can
|
||||
* render without an extra query per row.
|
||||
*
|
||||
* carrier-agnostic by design — see Modules\Core\Shipping\Contracts\
|
||||
* SupportsManifestBatching, the same contract any future carrier
|
||||
* (Speedex, etc.) implements to get manifest batching at all; this table
|
||||
* has no ACS-specific columns.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('manifests', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('carrier');
|
||||
$table->string('reference');
|
||||
$table->unsignedInteger('shipment_count')->default(0);
|
||||
$table->timestamp('issued_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['carrier', 'reference']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('manifests');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Replaces the loose manifest_reference string with a real manifests
|
||||
* relation — see 2026_09_13_000002_create_manifests_table.php. Backfills
|
||||
* one Manifest row per distinct (carrier, manifest_reference) pair
|
||||
* already present in shipments, using the earliest label_printed_at (or
|
||||
* updated_at as a fallback) among that group as a best-effort issued_at,
|
||||
* since the exact original issue time was never recorded anywhere.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('shipments', function (Blueprint $table) {
|
||||
$table->foreignId('manifest_id')->nullable()->after('manifest_reference')->constrained()->nullOnDelete();
|
||||
});
|
||||
|
||||
$groups = DB::table('shipments')
|
||||
->select('carrier', 'manifest_reference')
|
||||
->whereNotNull('manifest_reference')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$shipments = DB::table('shipments')
|
||||
->where('carrier', $group->carrier)
|
||||
->where('manifest_reference', $group->manifest_reference)
|
||||
->get();
|
||||
|
||||
$issuedAt = $shipments->pluck('label_printed_at')->filter()->min()
|
||||
?? $shipments->pluck('updated_at')->min();
|
||||
|
||||
$manifestId = DB::table('manifests')->insertGetId([
|
||||
'carrier' => $group->carrier,
|
||||
'reference' => $group->manifest_reference,
|
||||
'shipment_count' => $shipments->count(),
|
||||
'issued_at' => $issuedAt,
|
||||
'created_at' => $issuedAt,
|
||||
'updated_at' => $issuedAt,
|
||||
]);
|
||||
|
||||
DB::table('shipments')
|
||||
->where('carrier', $group->carrier)
|
||||
->where('manifest_reference', $group->manifest_reference)
|
||||
->update(['manifest_id' => $manifestId]);
|
||||
}
|
||||
|
||||
Schema::table('shipments', function (Blueprint $table) {
|
||||
$table->dropColumn('manifest_reference');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('shipments', function (Blueprint $table) {
|
||||
$table->string('manifest_reference')->nullable()->after('parent_reference');
|
||||
});
|
||||
|
||||
DB::table('shipments')
|
||||
->whereNotNull('manifest_id')
|
||||
->orderBy('id')
|
||||
->each(function ($shipment) {
|
||||
$manifest = DB::table('manifests')->find($shipment->manifest_id);
|
||||
|
||||
if ($manifest) {
|
||||
DB::table('shipments')->where('id', $shipment->id)->update([
|
||||
'manifest_reference' => $manifest->reference,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('shipments', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('manifest_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is complete. Thanks for shopping with us!</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
|
||||
@@ -1,3 +0,0 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@@ -297,6 +297,7 @@ class CheckoutService
|
||||
|
||||
$order->meta = [
|
||||
...($order->meta?->toArray() ?? []),
|
||||
'payment_method' => $type,
|
||||
'terms_accepted' => true,
|
||||
'terms_accepted_at' => now()->toIso8601String(),
|
||||
'terms_accepted_policy_version' => $policyVersion,
|
||||
|
||||
@@ -311,9 +311,8 @@ class InstallLunarCommand extends Command
|
||||
PaymentMethod::create([
|
||||
'type' => 'cash-on-delivery',
|
||||
'name' => 'Cash on Delivery',
|
||||
'driver' => 'offline',
|
||||
'driver' => 'cash-on-delivery',
|
||||
'capture_mode' => 'pay',
|
||||
'captured_status' => 'payment-offline',
|
||||
'position' => 0,
|
||||
'enabled' => false,
|
||||
'data' => [],
|
||||
|
||||
+8
-4
@@ -27,15 +27,18 @@ use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
|
||||
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
|
||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension;
|
||||
use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension;
|
||||
use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension;
|
||||
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
|
||||
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
|
||||
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
use Modules\Core\Shipping\Extensions\OrderShipmentsExtension;
|
||||
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;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
|
||||
|
||||
class CorePlugin implements Plugin
|
||||
{
|
||||
@@ -55,9 +58,10 @@ class CorePlugin implements Plugin
|
||||
LanguageLineResource::class,
|
||||
CartResource::class,
|
||||
PaymentMethodResource::class,
|
||||
ShipmentResource::class,
|
||||
ManifestResource::class,
|
||||
])
|
||||
->plugin(ShippingPlugin::make())
|
||||
->pages([ManagePickupManifests::class]);
|
||||
->plugin(ShippingPlugin::make());
|
||||
|
||||
LunarPanel::extensions([
|
||||
StaffResource::class => StaffResourceExtension::class,
|
||||
@@ -66,7 +70,7 @@ class CorePlugin implements Plugin
|
||||
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
|
||||
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
|
||||
ListShippingMethod::class => ShippingMethodListExtension::class,
|
||||
ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class],
|
||||
ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
|
||||
OrderItemsTable::class => OrderItemsTableExtension::class,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Events\OrderCompleted;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
|
||||
/**
|
||||
* Auto-completes a carrier order once its 14-day return window has
|
||||
* elapsed with no return requested — the automatic counterpart to the
|
||||
* staff "Update Status" action's manual completion. Store-pickup orders
|
||||
* have no return-window step at all (Modules\Core\Order\Listeners\
|
||||
* CompleteOrderOnPickedUp completes them immediately), so this only ever
|
||||
* touches carrier orders sitting in 'delivered' (the status also carrying
|
||||
* "return window is open" — see AdvanceFulfillmentOnDelivered).
|
||||
*
|
||||
* Registered at exactly dailyAt('00:00') in
|
||||
* Modules\Core\Providers\OrderServiceProvider — a compliance requirement
|
||||
* that this run at exact midnight, not Laravel's own arbitrary default
|
||||
* time for a plain daily() schedule.
|
||||
*
|
||||
* "When did the window open" is read from order_status_transitions rather
|
||||
* than Order::updated_at, which any unrelated field write would bump —
|
||||
* this is the concrete reason the audit table exists beyond pure logging.
|
||||
*
|
||||
* Window length is config('core.order.return_window_days') — a legal/
|
||||
* policy value a store may need to change without a code deploy, not a
|
||||
* hardcoded constant.
|
||||
*/
|
||||
class CloseExpiredReturnWindows extends Command
|
||||
{
|
||||
protected $signature = 'boboko:order:close-expired-return-windows';
|
||||
|
||||
protected $description = 'Auto-complete carrier orders whose return window has elapsed with no return requested.';
|
||||
|
||||
public function handle(OrderStatusWriter $writer): void
|
||||
{
|
||||
$cutoff = now()->subDays(config('core.order.return_window_days', 14));
|
||||
|
||||
$orderIds = Order::query()
|
||||
->where('status', 'delivered')
|
||||
->whereHas('statusTransitions', function ($query) use ($cutoff) {
|
||||
$query->where('to_status', 'delivered')
|
||||
->where('created_at', '<=', $cutoff);
|
||||
})
|
||||
->pluck('id');
|
||||
|
||||
$completed = 0;
|
||||
|
||||
foreach ($orderIds as $orderId) {
|
||||
$order = Order::find($orderId);
|
||||
|
||||
if (! $order || $order->status !== 'delivered') {
|
||||
continue; // idempotent no-op — moved on since the query ran
|
||||
}
|
||||
|
||||
$writer->write($order, 'completed', self::class);
|
||||
|
||||
OrderCompleted::dispatch($order);
|
||||
|
||||
$completed++;
|
||||
}
|
||||
|
||||
$this->components->info("Completed {$completed} order(s) past their return window.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\DTOs;
|
||||
|
||||
/**
|
||||
* What a Modules\Core\Order\Services\OrderFulfillmentService method
|
||||
* returns instead of throwing/echoing a Filament notification directly —
|
||||
* keeps that service usable outside a Filament action (a future API
|
||||
* endpoint, a console command, a test) without dragging
|
||||
* Filament\Notifications\Notification along. Modules\Core\Shipping\
|
||||
* Extensions\OrderViewExtension is the one place that translates this
|
||||
* into an actual on-screen notification.
|
||||
*/
|
||||
final class OrderFulfillmentResult
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly string $message,
|
||||
) {}
|
||||
|
||||
public static function success(string $message): self
|
||||
{
|
||||
return new self(true, $message);
|
||||
}
|
||||
|
||||
public static function failure(string $message): self
|
||||
{
|
||||
return new self(false, $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* The one terminal signal every notification/reporting concern that only
|
||||
* cares about "this order is fully done" should listen to, regardless of
|
||||
* which path actually got it there — dispatched by all four:
|
||||
* Modules\Core\Order\Listeners\CompleteOrderOnPickedUp (store-pickup),
|
||||
* Modules\Core\Order\Commands\CloseExpiredReturnWindows (carrier,
|
||||
* automatic 14-day return-window expiry), or Modules\Core\Shipping\
|
||||
* Extensions\OrderViewExtension's "Mark Completed" action (manual
|
||||
* universal fallback, either branch).
|
||||
*/
|
||||
class OrderCompleted
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Models\ShipmentInfo;
|
||||
|
||||
/**
|
||||
* Dispatched by either of the two paths that move a carrier order's
|
||||
* `status` to 'dispatched' — Modules\Core\Order\Listeners\
|
||||
* AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real
|
||||
* carrier checkpoint) or Modules\Core\Order\Services\
|
||||
* OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via
|
||||
* the single "Update Status" action). $shipmentInfo is nullable
|
||||
* specifically because of that second path — populated with the
|
||||
* triggering checkpoint when it's real, null when staff drove it
|
||||
* manually. Mirrors OrderDelivered's {order, shipmentInfo} shape, just
|
||||
* with the nullability this one event additionally needs.
|
||||
*/
|
||||
class OrderDispatched
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly ?ShipmentInfo $shipmentInfo = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Order\Services\OrderStatusWriter::markPaid()
|
||||
* whenever Order::paid flips to true — entirely independent of the
|
||||
* `status` column (see OrderStatusFlow's own docblock for why payment
|
||||
* timing, especially for cash-on-delivery, cannot be modeled as a step in
|
||||
* that sequence). Order::status changes are instead picked up generically
|
||||
* by Modules\Core\Order\Events\OrderStatusUpdated (dispatched by
|
||||
* OrderObserver whenever `status` changes, regardless of writer).
|
||||
*/
|
||||
class OrderPaidChanged
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly string $causeClass,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Order\Services\OrderFulfillmentService::
|
||||
* markPickedUp(), the staff-driven "Update Status" action's handling of
|
||||
* the 'picked_up' target — the customer has collected a store-pickup
|
||||
* order in person. Store-pickup only; a carrier order's equivalent
|
||||
* "arrived" moment is OrderDelivered. Modules\Core\Order\Listeners\
|
||||
* CompleteOrderOnPickedUp reacts to this by moving `status` straight to
|
||||
* 'completed' — no return-window step for store-pickup, per the business
|
||||
* design.
|
||||
*/
|
||||
class OrderPickedUp
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Shipping\Extensions\OrderViewExtension's
|
||||
* "Mark Ready" action, carrier branch (Order::isStorePickupOrder() ===
|
||||
* false) — staff has packed/staged the order for carrier handoff.
|
||||
* Staff-internal: nothing customer-facing happens at this moment, so no
|
||||
* notification listens to this event (compare OrderReadyForPickup, which
|
||||
* does trigger a customer email).
|
||||
*/
|
||||
class OrderReadyForDispatch
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Shipping\Extensions\OrderViewExtension's
|
||||
* "Mark Ready" action, store-pickup branch (Order::isStorePickupOrder()
|
||||
* === true) — staff has packed/staged the order for the customer to
|
||||
* collect in store. Drives Modules\Core\Order\Notifications\
|
||||
* OrderPickupReadyNotification ("come collect your order").
|
||||
*/
|
||||
class OrderReadyForPickup
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Order\Services\OrderStatusWriter::write()
|
||||
* alongside the generic Modules\Core\Order\Events\OrderStatusUpdated
|
||||
* (which Modules\Core\Order\Observers\OrderObserver dispatches for ANY
|
||||
* `status` write, regardless of cause, and which
|
||||
* OrderStatusUpdatedNotification already listens to). This event exists
|
||||
* only because the audit trail (Modules\Core\Order\Listeners\
|
||||
* RecordStatusTransition) needs $causeClass, which OrderStatusUpdated
|
||||
* does not carry — OrderStatusWriter is the only writer of `status` this
|
||||
* package has left, so it's the only place that needs to know its own
|
||||
* cause.
|
||||
*/
|
||||
class OrderStatusChanged
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly ?string $previousStatus,
|
||||
public readonly string $newStatus,
|
||||
public readonly string $causeClass,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Filament\Extensions;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Lunar\Admin\Support\Extending\ViewPageExtension;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
|
||||
/**
|
||||
* Adds a "Payment Method" entry to the order summary sidebar — previously
|
||||
* nowhere on the order page told staff which payment method a shopper
|
||||
* actually used. Reads Order.meta['payment_method'] (written by
|
||||
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment()), the
|
||||
* same source Modules\Core\Order\Services\OrderStatusFlow::isCod() reads,
|
||||
* so this entry and the "Mark Paid" action's visibility always agree on
|
||||
* what payment method an order used. Falls back to the most recent
|
||||
* Transaction.driver for an order placed before that field existed.
|
||||
*
|
||||
* Uses the extendOrderSummarySchema hook, same as the deleted 3-axis
|
||||
* OrderStatusSummaryExtension did — see that class's git history for the
|
||||
* hook's own docblock/rationale.
|
||||
*/
|
||||
class OrderPaymentMethodSummaryExtension extends ViewPageExtension
|
||||
{
|
||||
public function extendOrderSummarySchema(array $schema): array
|
||||
{
|
||||
$schema[] = TextEntry::make('payment_method')
|
||||
->label('Payment method')
|
||||
->state(fn (Order $record) => $this->resolveLabel($record))
|
||||
->placeholder('—')
|
||||
->alignEnd();
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
private function resolveLabel(Order $record): ?string
|
||||
{
|
||||
$type = $record->meta['payment_method'] ?? $record->transactions()->latest('id')->value('driver');
|
||||
|
||||
if ($type === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PaymentMethod::where('type', $type)->value('name') ?? $type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderDispatched;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
/**
|
||||
* The automatic half of "Dispatched" — the manual fallback is the staff
|
||||
* "Update Status" action (Modules\Core\Shipping\Extensions\
|
||||
* OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly,
|
||||
* the same event Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment
|
||||
* listens to.
|
||||
*
|
||||
* Reacts to either TrackingStatus::CollectedFromSender (the carrier
|
||||
* collected the parcel from the merchant) or InTransit directly, for a
|
||||
* carrier that skips straight there without a distinct collection
|
||||
* checkpoint.
|
||||
*
|
||||
* Guarded to only fire from 'ready_for_dispatch' — a late/duplicate
|
||||
* checkpoint, or an order the manual action already advanced, is a
|
||||
* silent no-op.
|
||||
*/
|
||||
class AdvanceFulfillmentOnCarrierCheckpoint
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(ShipmentStatusUpdatedByCarrier $event): void
|
||||
{
|
||||
if ($event->shipmentInfo->status !== TrackingStatus::InTransit
|
||||
&& $event->shipmentInfo->status !== TrackingStatus::CollectedFromSender) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $event->shipmentInfo->shipment->order;
|
||||
|
||||
if (! $order || $order->status !== 'ready_for_dispatch') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'dispatched', self::class);
|
||||
|
||||
OrderDispatched::dispatch($order, $event->shipmentInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
|
||||
/**
|
||||
* Writes `status` to 'delivered' once a carrier confirms delivery, rather
|
||||
* than jumping straight to 'completed'. Carrier orders get a return
|
||||
* window between delivery and completion (see Modules\Core\Order\
|
||||
* Commands\CloseExpiredReturnWindows, which auto-completes an order once
|
||||
* that window elapses) — 'delivered' is both "the parcel arrived" and
|
||||
* "the return window is now open"; nothing distinguishes those as
|
||||
* separate instants, they're the same moment, so there is only the one
|
||||
* status value.
|
||||
*
|
||||
* Kept separate from Modules\Core\Order\Listeners\
|
||||
* DeriveOrderDeliveredFromShipment, which only ever dispatches
|
||||
* OrderDelivered — deriving "was this delivered" and acting on it by
|
||||
* writing `status` are deliberately two different listeners.
|
||||
*
|
||||
* Guarded to only fire from 'dispatched' — a duplicate/late Delivered
|
||||
* checkpoint, or an order a manual action already moved past, is a
|
||||
* silent no-op.
|
||||
*/
|
||||
class AdvanceFulfillmentOnDelivered
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(OrderDelivered $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
if ($order->status !== 'dispatched') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'delivered', self::class);
|
||||
}
|
||||
}
|
||||
@@ -5,43 +5,45 @@ namespace Modules\Core\Order\Listeners;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Order\Enums\PaymentStatus;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
use Modules\Core\Order\Support\OrderStatus;
|
||||
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
use Modules\Core\Payment\Services\PaymentMethodCache;
|
||||
|
||||
/**
|
||||
* The only place an Order's status column is written in reaction to a
|
||||
* payment outcome. Registered against PaymentCaptured, PaymentAuthorized,
|
||||
* AND PaymentRefunded (see OrderServiceProvider) — same handler for all
|
||||
* three, differing only in which PaymentMethod column decides the
|
||||
* resulting status and, for a refund, which PaymentMethod row that even
|
||||
* is (see resolvePaymentMethod()).
|
||||
* Registered against PaymentCaptured, PaymentAuthorized, AND
|
||||
* PaymentRefunded (see OrderServiceProvider).
|
||||
*
|
||||
* A capture/authorization only ever writes Order::paid/paid_at (via
|
||||
* OrderStatusWriter::markPaid()) — never `status`. Confirmed with the
|
||||
* user: status leaving 'awaiting_payment' is always a staff-driven
|
||||
* "Update Status" click, regardless of payment method — no special-casing
|
||||
* prepaid vs. cash-on-delivery. A prepaid order briefly sitting at
|
||||
* 'awaiting_payment' with paid = true (until staff notice and advance it)
|
||||
* is expected, not a bug.
|
||||
*
|
||||
* A refund still moves `status` (returned -> refunded/partially_refunded)
|
||||
* — refunds are a normal step in Modules\Core\Order\Services\
|
||||
* OrderStatusFlow's own sequence, unlike captures. Derives
|
||||
* Refunded/PartialRefund from Modules\Core\Order\Support\OrderStatus::
|
||||
* payment() — the existing, unchanged derived-enum logic, reused rather
|
||||
* than reimplemented.
|
||||
*
|
||||
* Reads $event->context['order_id'] to find which Order this outcome
|
||||
* belongs to — Payment has no concept of an Order, so this is the one
|
||||
* place that context key gets consumed on the Order side (Payment's own
|
||||
* StripePaymentDriver reads $context['order_id'] independently, for its
|
||||
* own unrelated correlation need — see that class's rememberIntent()).
|
||||
* belongs to — Payment has no concept of an Order.
|
||||
*
|
||||
* Loads and saves the model (not a bulk ::whereKey()->update()) so
|
||||
* Order::observe()'s updated() hook fires and OrderStatusUpdated goes out
|
||||
* the same as any other status write — see that event's own docblock for
|
||||
* why it's meant to fire "regardless of what wrote it."
|
||||
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set.
|
||||
* Never fires from the PaymentRefunded path — a refund can only ever
|
||||
* happen after an order was already placed.
|
||||
*
|
||||
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set —
|
||||
* see that event's own docblock for why this, not CheckoutService, is now
|
||||
* the dispatch point. Never fires from the PaymentRefunded path — a
|
||||
* refund can only ever happen after an order was already placed.
|
||||
*
|
||||
* Deliberately does NOT react to PaymentVoided — see PaymentMethod's own
|
||||
* docblock for why there's no void_status column at all yet.
|
||||
* Deliberately does NOT react to PaymentVoided.
|
||||
*/
|
||||
class ApplyResolvedPaymentStatus
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaymentMethodCache $paymentMethods,
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
|
||||
@@ -54,59 +56,41 @@ class ApplyResolvedPaymentStatus
|
||||
|
||||
$order = Order::findOrFail($orderId);
|
||||
|
||||
$method = $this->resolvePaymentMethod($event, $order);
|
||||
$column = match (true) {
|
||||
$event instanceof PaymentCaptured => 'captured_status',
|
||||
$event instanceof PaymentAuthorized => 'authorized_status',
|
||||
$event instanceof PaymentRefunded => 'refunded_status',
|
||||
};
|
||||
$status = $method?->{$column};
|
||||
if ($event instanceof PaymentRefunded) {
|
||||
$this->applyRefund($order, $event);
|
||||
|
||||
if ($status === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wasPlaced = ! blank($order->placed_at);
|
||||
|
||||
$order->update([
|
||||
'status' => $status,
|
||||
'placed_at' => $order->placed_at ?? now(),
|
||||
]);
|
||||
$this->writer->markPaid($order, $event::class);
|
||||
|
||||
if (! $wasPlaced && ! $event instanceof PaymentRefunded) {
|
||||
if (! $wasPlaced) {
|
||||
$order->update(['placed_at' => $order->placed_at ?? now()]);
|
||||
Event::dispatch(new OrderPlaced($order));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PaymentCaptured/PaymentAuthorized carry $event->type as the
|
||||
* PaymentMethod.type that was actually charged — a direct lookup.
|
||||
*
|
||||
* PaymentRefunded's $event->type is the REFUND driver's own registry
|
||||
* key (e.g. 'bank-transfer' — see BankTransferPaymentDriver::refund()),
|
||||
* which may not correspond to any PaymentMethod row at all when the
|
||||
* admin refunded through a different driver than the one that took
|
||||
* the original payment (Payment\Support\TransactionDriverAdapter::
|
||||
* refundVia()). refunded_status is a business decision about the
|
||||
* ORIGINAL payment method, not the refund mechanism, so this instead
|
||||
* finds the order's earliest successful capture/intent transaction —
|
||||
* the actual payment the refund is reversing — and resolves that
|
||||
* transaction's own driver (a real PaymentMethod.type) instead.
|
||||
* Requires the refund Transaction row to already exist (Modules\Core\
|
||||
* Order\Listeners\RecordPaymentTransaction must run first — see
|
||||
* OrderServiceProvider's listener registration order for
|
||||
* PaymentRefunded), so the relation is refreshed here rather than
|
||||
* trusted from a possibly-stale $order instance.
|
||||
*/
|
||||
private function resolvePaymentMethod(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event, Order $order): ?PaymentMethod
|
||||
private function applyRefund(Order $order, PaymentRefunded $event): void
|
||||
{
|
||||
if (! $event instanceof PaymentRefunded) {
|
||||
return $this->paymentMethods->all()->firstWhere('type', $event->type);
|
||||
$order->load('transactions');
|
||||
|
||||
$target = match (OrderStatus::payment($order)) {
|
||||
PaymentStatus::Refunded => 'refunded',
|
||||
PaymentStatus::PartialRefund => 'partially_refunded',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($target !== null && $order->status !== $target) {
|
||||
$this->writer->write($order, $target, $event::class);
|
||||
}
|
||||
|
||||
$originalType = $order->transactions()
|
||||
->whereIn('type', ['capture', 'intent'])
|
||||
->where('success', true)
|
||||
->oldest('created_at')
|
||||
->value('driver');
|
||||
|
||||
return $originalType !== null
|
||||
? $this->paymentMethods->all()->firstWhere('type', $originalType)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
|
||||
/**
|
||||
* Writes Order.status to 'completed' once a carrier confirms delivery —
|
||||
* the terminal status a carrier-fulfilled order reaches on its own,
|
||||
* without a human picking it from the dropdown, mirroring the store-pickup
|
||||
* order's own terminal transition (Modules\Core\Shipping\Extensions\
|
||||
* OrderViewExtension::markPickedUpAction()).
|
||||
*
|
||||
* Kept separate from Modules\Core\Order\Listeners\
|
||||
* DeriveOrderDeliveredFromShipment, which only ever dispatches
|
||||
* OrderDelivered — see that event's own docblock ("Order.status itself is
|
||||
* left untouched here") for why deriving "was this delivered" and acting
|
||||
* on it by writing status are deliberately two different listeners, same
|
||||
* separation Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus
|
||||
* already has from the Payment* events it reacts to.
|
||||
*
|
||||
* Guarded to only fire from 'dispatched' — a checkpoint arriving out of
|
||||
* order, or against an order some other status flow has already moved
|
||||
* past, shouldn't silently force it to 'completed'.
|
||||
*/
|
||||
class CompleteOrderOnDelivered
|
||||
{
|
||||
public function handle(OrderDelivered $event): void
|
||||
{
|
||||
if ($event->order->status !== 'dispatched') {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->order->update(['status' => 'completed']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderCompleted;
|
||||
use Modules\Core\Order\Events\OrderPickedUp;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
|
||||
/**
|
||||
* The store-pickup mirror of AdvanceFulfillmentOnDelivered — reacts to
|
||||
* OrderPickedUp (dispatched by Modules\Core\Order\Services\
|
||||
* OrderFulfillmentService::markPickedUp() the moment staff confirm the
|
||||
* customer collected the order) by moving `status` straight to
|
||||
* 'completed'. No return-window step for store-pickup orders, per the
|
||||
* business design — unlike the carrier branch, there is no 'delivered'
|
||||
* intermediate value on this path.
|
||||
*
|
||||
* Guarded to only fire from 'picked_up' — a duplicate dispatch (e.g. a
|
||||
* stale page re-submitting the action) is a silent no-op.
|
||||
*/
|
||||
class CompleteOrderOnPickedUp
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(OrderPickedUp $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
if ($order->status !== 'picked_up') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'completed', self::class);
|
||||
|
||||
OrderCompleted::dispatch($order);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
/**
|
||||
* Wires TrackingStatus::Failed to the 'delivery_failed' status for the
|
||||
* first time — previously an unused enum case. Guarded to only fire from
|
||||
* 'dispatched': a stale/duplicate checkpoint, or an order a manual action
|
||||
* already moved past, is a silent no-op.
|
||||
*/
|
||||
class MarkDeliveryFailedOnCarrierCheckpoint
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(ShipmentStatusUpdatedByCarrier $event): void
|
||||
{
|
||||
if ($event->shipmentInfo->status !== TrackingStatus::Failed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $event->shipmentInfo->shipment->order;
|
||||
|
||||
if (! $order || $order->status !== 'dispatched') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'delivery_failed', self::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderPaidChanged;
|
||||
use Modules\Core\Order\Events\OrderStatusChanged;
|
||||
use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
|
||||
|
||||
/**
|
||||
* The one place order_status_transitions rows actually get written —
|
||||
* listens to OrderStatusChanged (every write of the single `status`
|
||||
* column, via Modules\Core\Order\Services\OrderStatusWriter::write()) and
|
||||
* OrderPaidChanged (every write of Order::paid, via
|
||||
* OrderStatusWriter::markPaid()). paid isn't really a "status", but gets
|
||||
* one consistent audit trail entry ('paid', with a null from_status)
|
||||
* rather than a second, separate table.
|
||||
*/
|
||||
class RecordStatusTransition
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusTransitionRecorder $recorder,
|
||||
) {}
|
||||
|
||||
public function handleStatusChanged(OrderStatusChanged $event): void
|
||||
{
|
||||
$this->recorder->record($event->order, $event->previousStatus, $event->newStatus, $event->causeClass);
|
||||
}
|
||||
|
||||
public function handlePaidChanged(OrderPaidChanged $event): void
|
||||
{
|
||||
$this->recorder->record($event->order, null, 'paid', $event->causeClass);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* One append-only row per write to Order::status (plus one synthetic
|
||||
* 'paid' entry per Order::paid write — see
|
||||
* Modules\Core\Order\Listeners\RecordStatusTransition) — see
|
||||
* database/migrations/2026_09_11_000002_create_order_status_transitions_table.php
|
||||
* and Modules\Core\Order\Services\OrderStatusTransitionRecorder, which is
|
||||
* the only thing that ever creates a row. Never updated after creation —
|
||||
* $timestamps is disabled since there's no updated_at column and
|
||||
* created_at is DB-defaulted (`useCurrent()`), not Eloquent-managed.
|
||||
*/
|
||||
class OrderStatusTransition extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderCompleted;
|
||||
|
||||
class OrderCompletedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderCompleted $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.completed.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderCompleted::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Your order :reference is complete', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.completed', [
|
||||
'reference' => $order->reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderDispatched;
|
||||
|
||||
/**
|
||||
* Fills a real, previously-unfilled customer-communication gap — before
|
||||
* this redesign nothing notified a customer when their carrier order left
|
||||
* the building at all.
|
||||
*/
|
||||
class OrderDispatchedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderDispatched $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.dispatched.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderDispatched::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Your order :reference is on its way', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.dispatched', [
|
||||
'reference' => $order->reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,21 @@ use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
use Modules\Core\Order\Events\OrderReadyForPickup;
|
||||
|
||||
/**
|
||||
* "Your order is ready to collect" — fires on the same OrderStatusUpdated
|
||||
* event Modules\Core\Order\Notifications\OrderStatusUpdatedNotification
|
||||
* listens to, but only for the 'ready-for-pickup' transition; that other
|
||||
* notification suppresses itself for this same transition (see its own
|
||||
* via()) so a customer gets this richer, pickup-specific email instead of
|
||||
* the generic "order updated" one, not both.
|
||||
* "Your order is ready to collect" — listens to the specific
|
||||
* OrderReadyForPickup event (dispatched by Modules\Core\Shipping\
|
||||
* Extensions\OrderViewExtension's "Mark Ready" action, store-pickup
|
||||
* branch only), not the generic OrderStatusUpdated. Modules\Core\Order\
|
||||
* Notifications\OrderStatusUpdatedNotification still separately
|
||||
* suppresses itself for the legacy 'ready-for-pickup' status string, kept
|
||||
* defensively even though nothing writes that literal value to
|
||||
* Order::status anymore after this redesign.
|
||||
*/
|
||||
class OrderPickupReadyNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderStatusUpdated $event) {}
|
||||
public function __construct(private readonly OrderReadyForPickup $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
@@ -27,15 +29,11 @@ class OrderPickupReadyNotification extends BaseNotification
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderStatusUpdated::class;
|
||||
return OrderReadyForPickup::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
if ($this->event->newStatus !== 'ready-for-pickup') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,32 @@ namespace Modules\Core\Order\Observers;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
|
||||
/**
|
||||
* Generically dispatches OrderStatusUpdated for ANY write to `status`,
|
||||
* regardless of what wrote it (Modules\Core\Order\Services\
|
||||
* OrderStatusWriter, artisan tinker, a future API) — the general-purpose
|
||||
* hook notifications listen to. OrderStatusWriter separately dispatches
|
||||
* its own OrderStatusChanged (carrying $causeClass, which this event does
|
||||
* not) for the audit trail — see Modules\Core\Order\Listeners\
|
||||
* RecordStatusTransition.
|
||||
*
|
||||
* Does NOT try to generically watch Order::paid — an earlier design had
|
||||
* this observer thread a "what caused this" value through a runtime
|
||||
* $order->statusTransitionCause property, abandoned because
|
||||
* Lunar\Models\Order's $guarded = [] means Eloquent tries to persist any
|
||||
* property set that way as a real column. OrderStatusWriter::markPaid()
|
||||
* dispatches OrderPaidChanged directly instead.
|
||||
*/
|
||||
class OrderObserver
|
||||
{
|
||||
public function updated(Order $order): void
|
||||
{
|
||||
if (! $order->wasChanged('status')) {
|
||||
return;
|
||||
if ($order->wasChanged('status')) {
|
||||
OrderStatusUpdated::dispatch(
|
||||
$order,
|
||||
$order->getOriginal('status'),
|
||||
$order->status,
|
||||
);
|
||||
}
|
||||
|
||||
OrderStatusUpdated::dispatch(
|
||||
$order,
|
||||
$order->getOriginal('status'),
|
||||
$order->status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Modules\Core\Order\DTOs\OrderFulfillmentResult;
|
||||
use Modules\Core\Order\Events\OrderPickedUp;
|
||||
use Modules\Core\Order\Events\OrderReadyForDispatch;
|
||||
use Modules\Core\Order\Events\OrderReadyForPickup;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The staff-facing fulfillment/return/payment workflow behind the three
|
||||
* header actions in Modules\Core\Shipping\Extensions\OrderViewExtension
|
||||
* ("Create Shipment", "Update Status", "Mark Paid") — every guard check,
|
||||
* status write (via Modules\Core\Order\Services\OrderStatusWriter), and
|
||||
* event dispatch lives here, keeping this workflow usable and testable
|
||||
* independent of Filament.
|
||||
*
|
||||
* Every method re-validates its own precondition internally (not just
|
||||
* trusted from the caller's own visible()-equivalent check) — protects
|
||||
* against a stale page load racing a concurrent automatic transition
|
||||
* (e.g. a carrier tracking checkpoint advancing the same order between
|
||||
* page load and button click).
|
||||
*/
|
||||
class OrderFulfillmentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
private readonly OrderStatusFlow $flow,
|
||||
) {}
|
||||
|
||||
public function markReady(Order $order): OrderFulfillmentResult
|
||||
{
|
||||
if ($order->status !== 'processing') {
|
||||
return OrderFulfillmentResult::failure('This order must be in Processing before it can be marked ready.');
|
||||
}
|
||||
|
||||
$target = $order->isStorePickupOrder() ? 'ready_for_pickup' : 'ready_for_dispatch';
|
||||
|
||||
$this->writer->write($order, $target, self::class.'::markReady');
|
||||
|
||||
if ($order->isStorePickupOrder()) {
|
||||
OrderReadyForPickup::dispatch($order);
|
||||
} else {
|
||||
OrderReadyForDispatch::dispatch($order);
|
||||
}
|
||||
|
||||
return OrderFulfillmentResult::success('Order marked ready.');
|
||||
}
|
||||
|
||||
public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult
|
||||
{
|
||||
if ($order->status !== 'ready_for_dispatch') {
|
||||
return OrderFulfillmentResult::failure('This order is not ready to be dispatched.');
|
||||
}
|
||||
|
||||
$service = $this->resolveFulfillmentService($order);
|
||||
|
||||
if (! $service) {
|
||||
return OrderFulfillmentResult::failure('No carrier fulfillment integration is configured for this order.');
|
||||
}
|
||||
|
||||
try {
|
||||
$service->createShipment($order, $request);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch');
|
||||
|
||||
return OrderFulfillmentResult::success('Shipment created and order dispatched.');
|
||||
}
|
||||
|
||||
public function markPickedUp(Order $order): OrderFulfillmentResult
|
||||
{
|
||||
if ($order->status !== 'ready_for_pickup') {
|
||||
return OrderFulfillmentResult::failure('This order is not ready for pickup.');
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'picked_up', self::class.'::markPickedUp');
|
||||
|
||||
OrderPickedUp::dispatch($order);
|
||||
|
||||
return OrderFulfillmentResult::success('Order marked as picked up.');
|
||||
}
|
||||
|
||||
/**
|
||||
* The general-purpose entry point for any transition with no special
|
||||
* side effect — a manual override, not restricted to the guided next
|
||||
* step(s), so staff can revert to an earlier status in the order's
|
||||
* own branch. Validates $to is actually a member of
|
||||
* OrderStatusFlow::allOptions() before writing (server-side
|
||||
* re-validation of whatever the Select offered) — still refuses a
|
||||
* status from the WRONG branch or an unknown value.
|
||||
*/
|
||||
public function transitionTo(Order $order, string $to): OrderFulfillmentResult
|
||||
{
|
||||
if (! array_key_exists($to, $this->flow->allOptions($order))) {
|
||||
return OrderFulfillmentResult::failure('That status is not valid for this order.');
|
||||
}
|
||||
|
||||
$this->writer->write($order, $to, self::class.'::transitionTo');
|
||||
|
||||
return OrderFulfillmentResult::success('Order status updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Independent of `status` entirely — offered by the single "Update
|
||||
* Status" action regardless of current status (see
|
||||
* OrderStatusFlow::canMarkPaid()).
|
||||
*/
|
||||
public function markPaid(Order $order): OrderFulfillmentResult
|
||||
{
|
||||
if (! $this->flow->canMarkPaid($order)) {
|
||||
return OrderFulfillmentResult::failure('This order cannot be marked paid right now.');
|
||||
}
|
||||
|
||||
$this->writer->markPaid($order, self::class.'::markPaid');
|
||||
|
||||
return OrderFulfillmentResult::success('Order marked as paid.');
|
||||
}
|
||||
|
||||
public function canCreateShipment(Order $order): bool
|
||||
{
|
||||
return $order->status === 'ready_for_dispatch'
|
||||
&& ! $order->isStorePickupOrder()
|
||||
&& $order->shipments()->exists() === false
|
||||
&& $this->resolveFulfillmentService($order) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public wrapper around resolveCarrier() — Modules\Core\Shipping\
|
||||
* Extensions\OrderViewExtension needs to know which carrier an order
|
||||
* uses to branch the "Create Shipment" form (Box Now's box-size
|
||||
* repeater vs. every other carrier's plain weight field).
|
||||
*/
|
||||
public function carrierFor(Order $order): ?string
|
||||
{
|
||||
return $this->resolveCarrier($order);
|
||||
}
|
||||
|
||||
private function resolveCarrier(Order $order): ?string
|
||||
{
|
||||
$code = $order->shippingAddress?->shipping_option;
|
||||
|
||||
if (! $code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ShippingMethod::where('code', $code)->value('driver');
|
||||
}
|
||||
|
||||
private function resolveFulfillmentService(Order $order): ?CarrierFulfillmentInterface
|
||||
{
|
||||
$carrier = $this->resolveCarrier($order);
|
||||
|
||||
if (! $carrier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
|
||||
/**
|
||||
* Two status sequences — carrier, pickup (Order::isStorePickupOrder()) —
|
||||
* NOT four. Payment method (prepaid vs. cash-on-delivery) does not affect
|
||||
* the status SEQUENCE at all; it only affects Order::paid, an entirely
|
||||
* separate field this class also offers a transition for (see
|
||||
* canMarkPaid()). `status` never includes a "paid" step — COD
|
||||
* reconciliation can happen at any point in, or after, the fulfillment
|
||||
* journey (same-day to months later), so it cannot occupy a fixed slot in
|
||||
* a linear sequence.
|
||||
*/
|
||||
class OrderStatusFlow
|
||||
{
|
||||
private const FLOW_CARRIER = [
|
||||
'awaiting_payment', 'processing', 'ready_for_dispatch', 'dispatched',
|
||||
'delivered', 'completed', 'return_requested', 'returned',
|
||||
];
|
||||
|
||||
private const FLOW_PICKUP = [
|
||||
'awaiting_payment', 'processing', 'ready_for_pickup', 'picked_up',
|
||||
'completed', 'return_requested', 'returned',
|
||||
];
|
||||
|
||||
private const REFUND_OPTIONS = ['partially_refunded', 'refunded'];
|
||||
|
||||
private const RETURN_ELIGIBLE_FROM = ['delivered', 'picked_up', 'completed'];
|
||||
|
||||
public function resolveFlow(Order $order): array
|
||||
{
|
||||
return $order->isStorePickupOrder() ? self::FLOW_PICKUP : self::FLOW_CARRIER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order.meta['payment_method'] (written by
|
||||
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment())
|
||||
* is the durable source of truth. Falls back to the most recent
|
||||
* Transaction.driver (a payment TYPE slug) only if meta is missing —
|
||||
* e.g. an order placed before this field existed.
|
||||
*/
|
||||
public function isCod(Order $order): bool
|
||||
{
|
||||
$type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver');
|
||||
|
||||
if ($type === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> value => label — every status in the
|
||||
* order's own branch (carrier or pickup), plus the refund options,
|
||||
* for a manual-override "New status" select. Deliberately not
|
||||
* filtered to nextOptions()'s guided next-step(s) — staff can jump
|
||||
* to any status in their branch, including reverting to an earlier
|
||||
* one (e.g. undoing a mistaken click). transitionTo() still
|
||||
* validates $to is actually a member of this set server-side.
|
||||
*/
|
||||
public function allOptions(Order $order): array
|
||||
{
|
||||
$statuses = [...$this->resolveFlow($order), ...self::REFUND_OPTIONS, 'delivery_failed'];
|
||||
|
||||
return collect($statuses)
|
||||
->unique()
|
||||
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> value => label — status-sequence
|
||||
* transitions offered as the guided next step(s). Does not include
|
||||
* the "mark paid" pseudo-option — see canMarkPaid().
|
||||
*/
|
||||
public function nextOptions(Order $order): array
|
||||
{
|
||||
$flow = $this->resolveFlow($order);
|
||||
$current = $order->status;
|
||||
$position = array_search($current, $flow, true);
|
||||
|
||||
$options = [];
|
||||
|
||||
if ($position !== false && isset($flow[$position + 1])) {
|
||||
$options[] = $flow[$position + 1];
|
||||
}
|
||||
|
||||
// delivery_failed — a possible outcome of any delivery attempt,
|
||||
// carrier flow only, checked on $current directly (not on the
|
||||
// flow's literal next value) since it's a branch on the attempt
|
||||
// itself, not on sequence position.
|
||||
if ($current === 'dispatched') {
|
||||
$options[] = 'delivery_failed';
|
||||
}
|
||||
|
||||
// From delivery_failed: retry dispatch, or give up and treat as
|
||||
// a return.
|
||||
if ($current === 'delivery_failed') {
|
||||
array_push($options, 'dispatched', 'return_requested');
|
||||
}
|
||||
|
||||
if (in_array($current, self::RETURN_ELIGIBLE_FROM, true)) {
|
||||
$options[] = 'return_requested';
|
||||
}
|
||||
|
||||
if ($current === 'return_requested') {
|
||||
$options[] = 'returned';
|
||||
}
|
||||
|
||||
if ($current === 'returned') {
|
||||
array_push($options, ...self::REFUND_OPTIONS);
|
||||
}
|
||||
|
||||
return collect($options)
|
||||
->unique()
|
||||
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "mark paid" option should be offered right now —
|
||||
* entirely independent of $order->status. True whenever this is a
|
||||
* cash-on-delivery order and payment hasn't been recorded yet,
|
||||
* regardless of fulfillment progress (before OR after completed).
|
||||
*/
|
||||
public function canMarkPaid(Order $order): bool
|
||||
{
|
||||
return ! $order->paid && $this->isCod($order);
|
||||
}
|
||||
|
||||
private function label(string $status): string
|
||||
{
|
||||
return (string) str($status)->replace('_', ' ')->title();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Models\OrderStatusTransition;
|
||||
|
||||
/**
|
||||
* The single place every order_status_transitions row gets written —
|
||||
* called by Modules\Core\Order\Listeners\RecordStatusTransition, itself
|
||||
* listening to Modules\Core\Order\Events\OrderStatusChanged and
|
||||
* OrderPaidChanged, dispatched by Modules\Core\Order\Services\
|
||||
* OrderStatusWriter (the only writer of Order::status/paid left in this
|
||||
* package).
|
||||
*/
|
||||
final class OrderStatusTransitionRecorder
|
||||
{
|
||||
public function record(Order $order, ?string $from, string $to, string $eventClass): void
|
||||
{
|
||||
OrderStatusTransition::create([
|
||||
'order_id' => $order->id,
|
||||
'from_status' => $from,
|
||||
'to_status' => $to,
|
||||
'event_class' => $eventClass,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Events\OrderPaidChanged;
|
||||
use Modules\Core\Order\Events\OrderStatusChanged;
|
||||
|
||||
/**
|
||||
* The one place Order::status/paid actually get written — replaces the
|
||||
* earlier per-axis Modules\Core\Order\Services\OrderAxisWriter now that
|
||||
* there is a single status column plus one independent `paid` field (see
|
||||
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
|
||||
* payment timing is not a status-sequence step).
|
||||
*
|
||||
* write() relies on Modules\Core\Order\Observers\OrderObserver to
|
||||
* generically dispatch OrderStatusUpdated whenever `status` actually
|
||||
* changes — there's no separate axis-changed event to dispatch here
|
||||
* anymore, since there's only one column left to watch. markPaid() is
|
||||
* genuinely independent: it dispatches its own OrderPaidChanged, since
|
||||
* OrderObserver only watches `status`, not `paid`.
|
||||
*
|
||||
* Cause is passed explicitly through every call rather than smuggled
|
||||
* through a runtime property on the model — Lunar\Models\Order has
|
||||
* $guarded = [], so Eloquent treats ANY property assignment as a real
|
||||
* column to persist; an earlier design that tried
|
||||
* $order->statusTransitionCause = ... broke immediately with an
|
||||
* "undefined column" error the moment ->update() ran.
|
||||
*/
|
||||
class OrderStatusWriter
|
||||
{
|
||||
public function write(Order $order, string $to, string $causeClass): void
|
||||
{
|
||||
$from = $order->status;
|
||||
|
||||
if ($from === $to) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order->update(['status' => $to]);
|
||||
|
||||
OrderStatusChanged::dispatch($order, $from, $to, $causeClass);
|
||||
}
|
||||
|
||||
public function markPaid(Order $order, string $causeClass): void
|
||||
{
|
||||
if ($order->paid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order->update(['paid' => true, 'paid_at' => now()]);
|
||||
|
||||
OrderPaidChanged::dispatch($order, $causeClass);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Drivers;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Lunar\DataTypes\Price;
|
||||
use Modules\Core\Payment\Contracts\Configurable;
|
||||
use Modules\Core\Payment\Contracts\SupportsPay;
|
||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||
|
||||
/**
|
||||
* Cash-on-delivery/cash-on-pickup — the shopper pays staff in person, at
|
||||
* delivery or pickup, not at checkout, and reconciliation can happen
|
||||
* anywhere from same-day to months later, entirely independent of the
|
||||
* order's fulfillment progress (this is WHY Order::paid is its own field,
|
||||
* not a status-sequence step — see Modules\Core\Order\Services\
|
||||
* OrderStatusFlow's own docblock).
|
||||
*
|
||||
* Unlike OfflinePaymentDriver (cash-in-hand, immediate capture), pay()
|
||||
* here must NOT dispatch PaymentCaptured — doing so would immediately
|
||||
* flip Order::paid via Modules\Core\Order\Listeners\
|
||||
* ApplyResolvedPaymentStatus, which is exactly wrong: no money has
|
||||
* changed hands yet. Returns PaymentResultStatus::Pending instead — the
|
||||
* documented convention for "unresolved" (see SupportsPay's own
|
||||
* docblock). ApplyResolvedPaymentStatus and RecordPaymentTransaction both
|
||||
* only listen to Captured/Authorized/Voided/Refunded, so a Pending result
|
||||
* triggers neither.
|
||||
*
|
||||
* Order::paid only ever becomes true for a COD order via staff explicitly
|
||||
* marking it received (Modules\Core\Order\Services\
|
||||
* OrderFulfillmentService::markPaid()), offered by the single "Update
|
||||
* Status" action at any time, independent of status.
|
||||
*/
|
||||
class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
|
||||
{
|
||||
return new PaymentResult(
|
||||
status: PaymentResultStatus::Pending,
|
||||
reference: 'cod-'.Str::uuid(),
|
||||
amount: $amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,12 @@ use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||
|
||||
/**
|
||||
* Shared by every payment type with no real gateway to confirm against —
|
||||
* cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on
|
||||
* delivery, not at checkout. There is no separate hold-then-settle model
|
||||
* Cash-in-hand — a shopper paying in person at the moment of pickup, with
|
||||
* nothing left to reconcile afterward, so capture is immediate. NOT used
|
||||
* for cash-on-delivery, which has its own Modules\Core\Payment\Drivers\
|
||||
* CashOnDeliveryPaymentDriver — COD payment happens at an unpredictable
|
||||
* later time (same-day to months), so it must not capture immediately the
|
||||
* way this driver does. There is no separate hold-then-settle model
|
||||
* (SupportsAuthorization/SupportsCaptures/SupportsVoids) and no async
|
||||
* resolution (HandlesPaymentCallback) — pay() decides success immediately
|
||||
* and dispatches PaymentCaptured before returning.
|
||||
|
||||
@@ -7,7 +7,6 @@ use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
@@ -55,11 +54,6 @@ use Modules\Core\Payment\Services\PaymentMethodService;
|
||||
* admin needs the same at-a-glance warning for it, not just a silently
|
||||
* absent checkout option.
|
||||
*
|
||||
* `authorized_status` only appears in the form when `capture_mode` is
|
||||
* "Hold now, charge later" — it's simply unreachable for a "Charge
|
||||
* immediately" method (that mode only ever produces PaymentCaptured,
|
||||
* never PaymentAuthorized), so showing it unconditionally would just be
|
||||
* a confusing, always-irrelevant field for most methods.
|
||||
*/
|
||||
class PaymentMethodResource extends Resource
|
||||
{
|
||||
@@ -151,13 +145,6 @@ class PaymentMethodResource extends Resource
|
||||
->default('pay')
|
||||
->live()
|
||||
->required(),
|
||||
static::getOrderStatusSelect('captured_status', 'Order status once paid')
|
||||
->helperText('Applied the moment a payment is fully charged.'),
|
||||
static::getOrderStatusSelect('authorized_status', 'Order status once held')
|
||||
->helperText('Applied the moment a hold is placed, before it\'s charged.')
|
||||
->visible(fn (Get $get) => $get('capture_mode') === 'authorize'),
|
||||
static::getOrderStatusSelect('refunded_status', 'Order status once refunded')
|
||||
->helperText('Applied when a payment taken through this method is refunded — even if the refund itself is processed through a different method.'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -169,24 +156,6 @@ class PaymentMethodResource extends Resource
|
||||
->required();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lunar's own Order::status is a plain, admin-extensible string
|
||||
* (config('lunar.orders.statuses')) rather than a fixed enum —
|
||||
* deliberately so a store can add its own custom status without a
|
||||
* code change (see docs/payments.md). This Select still reads from
|
||||
* that same open-ended list, just so an admin picks a real status
|
||||
* instead of typing a slug from memory.
|
||||
*/
|
||||
private static function getOrderStatusSelect(string $name, string $label): Select
|
||||
{
|
||||
return Select::make($name)
|
||||
->label($label)
|
||||
->options(collect(config('lunar.orders.statuses', []))
|
||||
->map(fn (array $status) => $status['label'] ?? $status)
|
||||
->all())
|
||||
->native(false);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
@@ -211,7 +180,7 @@ class PaymentMethodResource extends Resource
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->schema(static::getFormComponents())
|
||||
->fillForm(fn (PaymentMethod $record) => $record->only([
|
||||
'name', 'type', 'driver', 'capture_mode', 'captured_status', 'authorized_status', 'refunded_status',
|
||||
'name', 'type', 'driver', 'capture_mode',
|
||||
]))
|
||||
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
|
||||
}
|
||||
|
||||
@@ -18,16 +18,6 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* - capture_mode: 'pay' or 'authorize' — which SupportsPay/
|
||||
* SupportsAuthorization method CheckoutService::initiatePayment()
|
||||
* calls for this row.
|
||||
* - captured_status / authorized_status / refunded_status: the
|
||||
* Order::status value Modules\Core\Order\Listeners\
|
||||
* ApplyResolvedPaymentStatus applies on a PaymentCaptured/
|
||||
* PaymentAuthorized/PaymentRefunded event. For a refund, this is
|
||||
* always the ORIGINAL payment method's row (the one the customer
|
||||
* actually paid with), never the driver the refund itself was routed
|
||||
* through (Payment\Support\TransactionDriverAdapter::refundVia() may
|
||||
* use a different one entirely — e.g. a cash-on-delivery order
|
||||
* refunded via a Bank Transfer driver with no PaymentMethod row of
|
||||
* its own) — see that listener's own docblock.
|
||||
* - position: admin-controlled display/checkout order.
|
||||
* - driver_missing_at: set by `payment:sync-drivers` when `driver` no
|
||||
* longer resolves via the registry — separate from `enabled`, so a
|
||||
|
||||
@@ -70,8 +70,7 @@ class PaymentMethodService
|
||||
public function delete(PaymentMethod $method): void
|
||||
{
|
||||
$snapshot = $method->only([
|
||||
'id', 'type', 'name', 'driver', 'capture_mode',
|
||||
'captured_status', 'authorized_status', 'position', 'enabled',
|
||||
'id', 'type', 'name', 'driver', 'capture_mode', 'position', 'enabled',
|
||||
]);
|
||||
|
||||
$method->delete();
|
||||
|
||||
@@ -2,20 +2,33 @@
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Notification\NotificationRegistry;
|
||||
use Modules\Core\Order\Commands\CloseExpiredReturnWindows;
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
use Modules\Core\Order\Events\OrderPaidChanged;
|
||||
use Modules\Core\Order\Events\OrderPickedUp;
|
||||
use Modules\Core\Order\Events\OrderStatusChanged;
|
||||
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint;
|
||||
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnDelivered;
|
||||
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
|
||||
use Modules\Core\Order\Listeners\CompleteOrderOnDelivered;
|
||||
use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
|
||||
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
|
||||
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
|
||||
use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint;
|
||||
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
|
||||
use Modules\Core\Order\Listeners\RecordStatusTransition;
|
||||
use Modules\Core\Order\Models\OrderStatusTransition;
|
||||
use Modules\Core\Order\Notifications\OrderCapturedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderCompletedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
|
||||
use Modules\Core\Order\Notifications\OrderDispatchedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderPickupReadyNotification;
|
||||
use Modules\Core\Order\Notifications\OrderPlacedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderRefundedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
|
||||
@@ -38,23 +51,43 @@ class OrderServiceProvider extends ServiceProvider
|
||||
Order::macro('paymentStatus', fn () => OrderStatus::payment($this));
|
||||
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
|
||||
|
||||
Order::resolveRelationUsing('statusTransitions', function ($order) {
|
||||
return $order->hasMany(OrderStatusTransition::class);
|
||||
});
|
||||
|
||||
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
|
||||
Event::listen(OrderDelivered::class, CompleteOrderOnDelivered::class);
|
||||
Event::listen(ShipmentStatusUpdatedByCarrier::class, AdvanceFulfillmentOnCarrierCheckpoint::class);
|
||||
Event::listen(ShipmentStatusUpdatedByCarrier::class, MarkDeliveryFailedOnCarrierCheckpoint::class);
|
||||
Event::listen(OrderDelivered::class, AdvanceFulfillmentOnDelivered::class);
|
||||
Event::listen(OrderPickedUp::class, CompleteOrderOnPickedUp::class);
|
||||
|
||||
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
|
||||
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
|
||||
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
|
||||
Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class);
|
||||
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
|
||||
Event::listen(PaymentVoided::class, RecordPaymentTransaction::class);
|
||||
// RecordPaymentTransaction must run before ApplyResolvedPaymentStatus
|
||||
// for PaymentRefunded specifically — the latter now reads
|
||||
// $order->transactions (via OrderStatus::payment()) to resolve
|
||||
// Refunded vs PartiallyRefunded, which requires the refund
|
||||
// Transaction row to already exist.
|
||||
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
|
||||
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
|
||||
|
||||
Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class);
|
||||
|
||||
Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']);
|
||||
Event::listen(OrderPaidChanged::class, [RecordStatusTransition::class, 'handlePaidChanged']);
|
||||
|
||||
NotificationRegistry::get()->register([
|
||||
OrderDeliveredNotification::class,
|
||||
OrderStatusUpdatedNotification::class,
|
||||
OrderRefundedNotification::class,
|
||||
OrderCapturedNotification::class,
|
||||
OrderPlacedNotification::class,
|
||||
OrderPickupReadyNotification::class,
|
||||
OrderDispatchedNotification::class,
|
||||
OrderCompletedNotification::class,
|
||||
]);
|
||||
|
||||
// Lets the consuming app override copy/markup without forking core
|
||||
@@ -64,5 +97,17 @@ class OrderServiceProvider extends ServiceProvider
|
||||
$this->publishes([
|
||||
__DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'),
|
||||
], 'core-views');
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([CloseExpiredReturnWindows::class]);
|
||||
}
|
||||
|
||||
// Exact midnight per an explicit compliance requirement — not a
|
||||
// loose dailyAt() offset or plain daily().
|
||||
$this->app->booted(function () {
|
||||
$this->app->make(ConsoleSchedule::class)
|
||||
->command(CloseExpiredReturnWindows::class)
|
||||
->dailyAt('00:00');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Lunar\Models\Contracts\Transaction as TransactionContract;
|
||||
use Lunar\Pipelines\Cart\ApplyShipping;
|
||||
use Modules\Core\Command\SyncPaymentDriversCommand;
|
||||
use Modules\Core\Payment\Drivers\BankTransferPaymentDriver;
|
||||
use Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver;
|
||||
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
|
||||
use Modules\Core\Payment\Drivers\StripePaymentDriver;
|
||||
use Modules\Core\Payment\Events\PaymentMethodCreated;
|
||||
@@ -33,6 +34,7 @@ class PaymentServiceProvider extends ServiceProvider
|
||||
$registry->register('offline', OfflinePaymentDriver::class, 'Offline / Manual');
|
||||
$registry->register('stripe', StripePaymentDriver::class, 'Stripe');
|
||||
$registry->register('bank-transfer', BankTransferPaymentDriver::class, 'Bank Transfer');
|
||||
$registry->register('cash-on-delivery', CashOnDeliveryPaymentDriver::class, 'Cash on Delivery');
|
||||
|
||||
$cartPipeline = config('lunar.cart.pipelines.cart', []);
|
||||
$insertAfter = array_search(ApplyShipping::class, $cartPipeline, true);
|
||||
|
||||
@@ -65,24 +65,34 @@ class ShippingServiceProvider extends ServiceProvider
|
||||
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
|
||||
], 'core-config');
|
||||
|
||||
// Signed-URL auth only, same model as Lunar's own vendor
|
||||
// lunar.pdf.download route — see Modules\Core\Shipping\Http\
|
||||
// Controllers\DownloadShipmentLabelController's own docblock.
|
||||
$this->loadRoutesFrom(__DIR__ . '/../Shipping/routes/web.php');
|
||||
|
||||
Order::resolveRelationUsing('shipments', function ($order) {
|
||||
return $order->hasMany(Shipment::class);
|
||||
});
|
||||
|
||||
// ShippingMethod.data['fulfillment_type'] — see
|
||||
// ShippingMethodResourceExtension::fulfillmentTypeSelect() for
|
||||
// where it's set. Defaults to 'carrier' (false here) for any row
|
||||
// saved before this field existed.
|
||||
ShippingMethod::macro('isStorePickup', function () {
|
||||
/** @var ShippingMethod $this */
|
||||
return ($this->data['fulfillment_type'] ?? 'carrier') === 'store_pickup';
|
||||
});
|
||||
|
||||
// Order has no direct ShippingMethod relation — shippingAddress.
|
||||
// shipping_option is only ever a code string (see
|
||||
// Modules\Core\Shipping\Extensions\OrderViewExtension::
|
||||
// resolveCarrier() for the same lookup pattern already used to
|
||||
// resolve a carrier driver from it).
|
||||
//
|
||||
// Reads ShippingMethod.data['fulfillment_type'] directly rather
|
||||
// than through a ShippingMethod::macro('isStorePickup', ...) —
|
||||
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
|
||||
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
|
||||
// intercepts EVERY unmatched static call, including macro()
|
||||
// itself, and dispatches it as (new static)->macro(...) instead
|
||||
// of forwarding to Macroable — so a macro registered this way
|
||||
// silently never gets stored, and hasMacro() always returns
|
||||
// false. (Lunar\Models\Order is unaffected because it declares
|
||||
// its own macro() method directly, bypassing __callStatic
|
||||
// entirely — that's why Order::macro('isStorePickupOrder', ...)
|
||||
// below still works.) Defaults to 'carrier' (false) for any row
|
||||
// saved before this field existed.
|
||||
Order::macro('isStorePickupOrder', function () {
|
||||
/** @var Order $this */
|
||||
$code = $this->shippingAddress?->shipping_option;
|
||||
@@ -91,7 +101,14 @@ class ShippingServiceProvider extends ServiceProvider
|
||||
return false;
|
||||
}
|
||||
|
||||
return ShippingMethod::where('code', $code)->first()?->isStorePickup() ?? false;
|
||||
// ->value('data') is deliberately avoided here — on Postgres,
|
||||
// ->value()/->pluck() on a JSON column silently return the
|
||||
// wrong result (works fine in ->where(), not in a column
|
||||
// projection); loading the model and reading its cast
|
||||
// attribute avoids that entirely.
|
||||
$method = ShippingMethod::where('code', $code)->first();
|
||||
|
||||
return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup';
|
||||
});
|
||||
|
||||
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
|
||||
|
||||
@@ -14,6 +14,7 @@ use Modules\Core\Shipping\DTOs\ManifestResult;
|
||||
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||||
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Models\Manifest;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
|
||||
@@ -88,7 +89,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
||||
|
||||
public function cancelShipment(Shipment $shipment): void
|
||||
{
|
||||
if ($shipment->manifest_reference) {
|
||||
if ($shipment->manifest_id) {
|
||||
throw new RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
|
||||
}
|
||||
|
||||
@@ -103,7 +104,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
||||
{
|
||||
return Shipment::query()
|
||||
->where('carrier', 'acs')
|
||||
->whereNull('manifest_reference')
|
||||
->whereNull('manifest_id')
|
||||
->whereNull('cancelled_at')
|
||||
->get();
|
||||
}
|
||||
@@ -123,11 +124,18 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
||||
|
||||
$pickupListNo = (string) $response->valueOutput['PickupList_No'];
|
||||
|
||||
$manifest = Manifest::create([
|
||||
'carrier' => 'acs',
|
||||
'reference' => $pickupListNo,
|
||||
'shipment_count' => $shipments->count(),
|
||||
'issued_at' => now(),
|
||||
]);
|
||||
|
||||
$shipments->each(fn (Shipment $shipment) => $shipment->update([
|
||||
'manifest_reference' => $pickupListNo,
|
||||
'manifest_id' => $manifest->id,
|
||||
]));
|
||||
|
||||
return ManifestResult::success($pickupListNo, $shipments);
|
||||
return ManifestResult::success($manifest, $shipments);
|
||||
}
|
||||
|
||||
public function trackShipment(Shipment $shipment): Collection
|
||||
@@ -176,6 +184,11 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
||||
{
|
||||
$action = strtolower($action);
|
||||
|
||||
// TODO: no live ACS payload sample yet showing a distinct
|
||||
// collection checkpoint separate from transit ("arrival"/
|
||||
// "departure" already map to InTransit) — add a str_contains()
|
||||
// arm mapping to TrackingStatus::CollectedFromSender here once
|
||||
// one is confirmed.
|
||||
return match (true) {
|
||||
str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
|
||||
str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
|
||||
|
||||
@@ -11,6 +11,7 @@ use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||
use Modules\Core\Shipping\Concerns\CachesLivePricing;
|
||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||
use Modules\Core\Shipping\Support\WeightCalculator;
|
||||
|
||||
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||
{
|
||||
@@ -103,26 +104,6 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||
|
||||
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
|
||||
return max(WeightCalculator::totalKg($cart->lines), 0.5); // ACS minimum billable weight
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,21 @@ use Modules\Core\Shipping\Models\Shipment;
|
||||
* Box Now delivers to lockers, not addresses. The storefront locker-picker
|
||||
* is out of scope for this pass — createShipment() requires the chosen
|
||||
* locker's Box Now locationId via ShipmentRequest::$destinationLocationId
|
||||
* (e.g. set manually by admin staff until checkout UI exists).
|
||||
* (e.g. set manually by admin staff until checkout UI exists — see
|
||||
* Modules\Core\Shipping\Extensions\OrderViewExtension, which locks the
|
||||
* field instead once the shopper's own checkout selection is present in
|
||||
* $order->shippingAddress->meta['box_now_locker']).
|
||||
*
|
||||
* Box Now ships by compartment size, not weight — unlike ACS, which bills
|
||||
* by kg. One 'items' entry per box in ShipmentRequest::$boxes, so an order
|
||||
* needing more than one physical parcel (doesn't fit one compartment)
|
||||
* sends that many entries in a single delivery request rather than
|
||||
* several separate ones.
|
||||
*/
|
||||
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
|
||||
{
|
||||
private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
|
||||
|
||||
public function __construct(private readonly BoxNowClient $client) {}
|
||||
|
||||
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
||||
@@ -36,6 +47,10 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
|
||||
}
|
||||
|
||||
if (empty($request->boxes)) {
|
||||
throw new BoxNowApiException('At least one box (compartment size) is required for a Box Now shipment.');
|
||||
}
|
||||
|
||||
$isCod = $request->paymentMode === 'cod';
|
||||
|
||||
$response = $this->client->request('post', '/delivery-requests', [
|
||||
@@ -57,31 +72,37 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
'contactName' => trim("{$address->first_name} {$address->last_name}"),
|
||||
'locationId' => $destinationLocationId,
|
||||
],
|
||||
'items' => [
|
||||
[
|
||||
'id' => (string) $order->id,
|
||||
'name' => 'Order '.$order->reference,
|
||||
'value' => '0.00',
|
||||
'compartmentSize' => 1,
|
||||
'weight' => $request->weight ?? 0,
|
||||
],
|
||||
],
|
||||
'items' => collect($request->boxes)->values()->map(fn (string $size, int $index) => [
|
||||
'id' => $order->id.'-'.($index + 1),
|
||||
'name' => 'Order '.$order->reference.' (box '.($index + 1).')',
|
||||
'value' => '0.00',
|
||||
'compartmentSize' => self::COMPARTMENT_SIZES[$size] ?? self::COMPARTMENT_SIZES['S'],
|
||||
])->all(),
|
||||
]);
|
||||
|
||||
$parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
|
||||
'Box Now delivery request succeeded but returned no parcel id.',
|
||||
$response,
|
||||
));
|
||||
$parcels = collect($response['parcels'] ?? []);
|
||||
|
||||
return Shipment::create([
|
||||
if ($parcels->isEmpty()) {
|
||||
throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel ids.', $response);
|
||||
}
|
||||
|
||||
// One Shipment row per box/parcel — each is independently
|
||||
// trackable/printable/cancellable via its own tracking_reference
|
||||
// (printLabel()/cancelShipment()/trackShipment() below already
|
||||
// operate per-Shipment), even though all boxes were submitted in
|
||||
// one delivery request. Siblings are linked via the shared
|
||||
// delivery_request_id in meta.
|
||||
$shipments = $parcels->map(fn (array $parcel) => Shipment::create([
|
||||
'order_id' => $order->id,
|
||||
'carrier' => 'box-now',
|
||||
'tracking_reference' => $parcelId,
|
||||
'tracking_reference' => (string) $parcel['id'],
|
||||
'meta' => [
|
||||
'delivery_request_id' => $response['id'] ?? null,
|
||||
'locker_id' => $destinationLocationId,
|
||||
],
|
||||
]);
|
||||
]));
|
||||
|
||||
return $shipments->first();
|
||||
}
|
||||
|
||||
public function printLabel(Shipment $shipment): string
|
||||
@@ -136,6 +157,13 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
|
||||
private function mapState(string $state): TrackingStatus
|
||||
{
|
||||
// TODO: confirm against a live BoxNow webhook payload whether a
|
||||
// distinct collected-from-sender state exists (e.g. between 'new'
|
||||
// and 'in-transit') before mapping it to
|
||||
// TrackingStatus::CollectedFromSender — BoxNow's own model is
|
||||
// locker-drop-off-based, so it may not have one. No guessed match
|
||||
// arm added; 'new' still falls through to Pending, InTransit
|
||||
// remains the earliest recognized checkpoint.
|
||||
return match ($state) {
|
||||
'new' => TrackingStatus::Pending,
|
||||
'in-transit', 'in-depot' => TrackingStatus::InTransit,
|
||||
|
||||
@@ -3,24 +3,26 @@
|
||||
namespace Modules\Core\Shipping\DTOs;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Core\Shipping\Models\Manifest;
|
||||
|
||||
class ManifestResult
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly ?string $reference,
|
||||
public readonly ?Manifest $manifest,
|
||||
public readonly Collection $includedShipments,
|
||||
public readonly Collection $blockedShipments,
|
||||
public readonly ?string $reason,
|
||||
) {}
|
||||
|
||||
public static function success(string $reference, Collection $includedShipments): self
|
||||
public static function success(Manifest $manifest, Collection $includedShipments): self
|
||||
{
|
||||
return new self(true, $reference, $includedShipments, collect(), null);
|
||||
return new self(true, $manifest->reference, $manifest, $includedShipments, collect(), null);
|
||||
}
|
||||
|
||||
public static function blocked(Collection $blockedShipments, string $reason): self
|
||||
{
|
||||
return new self(false, null, collect(), $blockedShipments, $reason);
|
||||
return new self(false, null, null, collect(), $blockedShipments, $reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,27 @@ namespace Modules\Core\Shipping\DTOs;
|
||||
/**
|
||||
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
|
||||
* Every field is optional — a carrier reads only what it needs and ignores
|
||||
* the rest (e.g. destinationLocationId only matters to locker-delivery
|
||||
* carriers like Box Now; ACS has no use for it).
|
||||
* the rest (e.g. destinationLocationId/boxes only matter to locker-delivery
|
||||
* carriers like Box Now; ACS has no use for either — it ships by weight,
|
||||
* not by box/compartment size).
|
||||
*/
|
||||
class ShipmentRequest
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $boxes Box Now only — one entry per
|
||||
* physical parcel, each a compartment size ('S'|'M'|'L'). A single
|
||||
* shipment can be split across several lockers of the same
|
||||
* destinationLocationId's collection point, e.g. two Large boxes for
|
||||
* an order that doesn't fit one compartment. Empty for every other
|
||||
* carrier, which ships as a single package described by $weight
|
||||
* instead.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?float $weight = null,
|
||||
public readonly int $packageCount = 1,
|
||||
public readonly ?string $destinationLocationId = null,
|
||||
public readonly ?string $paymentMode = null,
|
||||
public readonly ?float $amountToCollect = null,
|
||||
public readonly array $boxes = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,25 @@ namespace Modules\Core\Shipping\Enums;
|
||||
enum TrackingStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
|
||||
/**
|
||||
* The carrier collected the parcel from the merchant — deliberately
|
||||
* NOT named "PickedUp" to avoid colliding with the unrelated
|
||||
* order status 'picked_up' (Modules\Core\Order\Services\
|
||||
* OrderStatusFlow), which means the opposite end of a different flow
|
||||
* (a CUSTOMER collecting a store-pickup order). Consumed by
|
||||
* Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint.
|
||||
*
|
||||
* Not yet mapped from either carrier driver's own checkpoint data —
|
||||
* see Carriers\BoxNow\BoxNowFulfillmentService::mapState() and
|
||||
* Carriers\Acs\AcsFulfillmentService::guessStatusFromAction() for
|
||||
* TODOs on confirming against real payload samples before adding a
|
||||
* mapping. Until then this case exists but nothing produces it, and
|
||||
* InTransit remains the effective first real checkpoint for both
|
||||
* carriers.
|
||||
*/
|
||||
case CollectedFromSender = 'collected_from_sender';
|
||||
|
||||
case InTransit = 'in_transit';
|
||||
case OutForDelivery = 'out_for_delivery';
|
||||
case Delivered = 'delivered';
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Lunar\Admin\Support\Extending\ViewPageExtension;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Adds a "Shipments" section to the order page's main column — previously
|
||||
* "Create Shipment" (Modules\Core\Shipping\Extensions\OrderViewExtension)
|
||||
* had no counterpart anywhere on the order to actually SEE what it
|
||||
* created (carrier, tracking reference, current status, whether a label's
|
||||
* been printed or the shipment cancelled). One row per Shipment record —
|
||||
* a Box Now order with several boxes shows one row per box/parcel (see
|
||||
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService, which
|
||||
* creates one Shipment row per parcel Box Now returns), not one row per
|
||||
* "Create Shipment" click.
|
||||
*
|
||||
* Uses the extendInfolistSchema hook (main column — alongside shipping
|
||||
* address, order lines, totals, transactions, timeline), not
|
||||
* extendInfolistAsideSchema (sidebar) — a shipment list can grow long
|
||||
* (multi-box Box Now orders, a re-dispatched order after a delivery
|
||||
* failure) and reads more naturally as a main-column section like
|
||||
* Transactions, not a compact sidebar entry.
|
||||
*
|
||||
* Each shipment renders as two inline-labelled lines (carrier + tracking
|
||||
* reference, then status + timestamp) rather than a grid of individually
|
||||
* stacked label/value blocks — Filament's own multi-column grid still
|
||||
* collapses to one column below its lg breakpoint (1024px), which is
|
||||
* exactly where the admin's main content area commonly sits with the
|
||||
* sidebar open, so a 5-6 field grid reads as a wall of repeated labels
|
||||
* there.
|
||||
*
|
||||
* "Print Label" opens Modules\Core\Shipping\Http\Controllers\
|
||||
* DownloadShipmentLabelController via a short-lived signed URL — the same
|
||||
* auth model (and Action wiring pattern) Lunar's own vendor PdfDownload
|
||||
* action uses for order PDFs. Previously the only place that called
|
||||
* CarrierFulfillmentInterface::printLabel() (Modules\Core\Shipping\
|
||||
* Filament\Pages\ManagePickupManifests) discarded the returned bytes
|
||||
* entirely — this is the first place that actually delivers a label to
|
||||
* staff.
|
||||
*/
|
||||
class OrderShipmentsExtension extends ViewPageExtension
|
||||
{
|
||||
/**
|
||||
* Inserted right after Transactions and before Timeline — vendor
|
||||
* ManageOrder::getInfolistSchema() builds this array as
|
||||
* [shipping, orderLines, orderTotals, transactions, timeline] (see
|
||||
* Lunar\Admin\...\Concerns\DisplaysTransactions/DisplaysTimeline), so
|
||||
* splicing at index 4 lands the new section there regardless of how
|
||||
* many earlier entries any OTHER extension on this same hook has
|
||||
* already added/removed — counting from the end (timeline is always
|
||||
* last) would be equally fragile to some other extension appending
|
||||
* its own section after timeline, so this anchors on the known
|
||||
* vendor order instead.
|
||||
*/
|
||||
public function extendInfolistSchema(array $schema): array
|
||||
{
|
||||
array_splice($schema, 4, 0, [$this->shipmentsSection()]);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
private function shipmentsSection(): Section
|
||||
{
|
||||
return Section::make('shipments')
|
||||
->heading('Shipments')
|
||||
->compact()
|
||||
->collapsed(fn ($record) => $record->shipments->isEmpty())
|
||||
->collapsible(fn ($record) => $record->shipments->isNotEmpty())
|
||||
->schema([
|
||||
RepeatableEntry::make('shipments')
|
||||
->hiddenLabel()
|
||||
->placeholder('No shipments have been created for this order yet.')
|
||||
->contained(true)
|
||||
->schema([
|
||||
TextEntry::make('tracking_reference')
|
||||
->label(fn (Shipment $record) => $this->carrierLabel($record))
|
||||
->inlineLabel()
|
||||
->copyable(),
|
||||
TextEntry::make('status')
|
||||
->label('Status')
|
||||
->inlineLabel()
|
||||
->state(fn (Shipment $record) => $this->statusLabel($record))
|
||||
->badge()
|
||||
->color(fn (Shipment $record) => $this->statusColor($record))
|
||||
->helperText(fn (Shipment $record) => $this->helperText($record))
|
||||
->suffixActions([
|
||||
Action::make('print_label')
|
||||
->label('Print Label')
|
||||
->icon('heroicon-o-printer')
|
||||
->url(fn (Shipment $record) => URL::temporarySignedRoute(
|
||||
'shipments.label',
|
||||
now()->addMinutes(5),
|
||||
['shipment' => $record->id],
|
||||
), shouldOpenInNewTab: true)
|
||||
->visible(fn (Shipment $record) => ! $record->cancelled_at),
|
||||
Action::make('cancel_shipment')
|
||||
->label('Cancel')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Cancels this shipment with the carrier. This cannot be undone.')
|
||||
->action(fn (Shipment $record) => $this->cancel($record))
|
||||
->visible(fn (Shipment $record) => ! $record->cancelled_at),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function carrierLabel(Shipment $record): string
|
||||
{
|
||||
return match ($record->carrier) {
|
||||
'acs' => 'ACS',
|
||||
'box-now' => 'Box Now',
|
||||
default => (string) str($record->carrier)->title(),
|
||||
};
|
||||
}
|
||||
|
||||
private function helperText(Shipment $record): string
|
||||
{
|
||||
$parts = ['Created '.$record->created_at->format('Y-m-d H:i')];
|
||||
|
||||
if ($record->carrier === 'box-now' && $locker = $record->meta['locker_id'] ?? null) {
|
||||
$parts[] = 'Locker '.$locker;
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
private function statusLabel(Shipment $record): string
|
||||
{
|
||||
if ($record->cancelled_at) {
|
||||
return 'Cancelled';
|
||||
}
|
||||
|
||||
$latest = $record->latestShipmentInfo();
|
||||
|
||||
return $latest ? (string) str($latest->status->value)->replace('_', ' ')->title() : 'Pending';
|
||||
}
|
||||
|
||||
private function statusColor(Shipment $record): string
|
||||
{
|
||||
if ($record->cancelled_at) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return match ($record->latestShipmentInfo()?->status?->value) {
|
||||
'delivered' => 'success',
|
||||
'failed', 'returned' => 'danger',
|
||||
'in_transit', 'out_for_delivery', 'collected_from_sender' => 'warning',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function cancel(Shipment $record): void
|
||||
{
|
||||
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $record->carrier]);
|
||||
|
||||
try {
|
||||
$service->cancelShipment($record);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title('Failed to cancel shipment: '.$e->getMessage())
|
||||
->color('danger')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Shipment cancelled.')
|
||||
->color('success')
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,90 @@
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Closure;
|
||||
use Throwable;
|
||||
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;
|
||||
use Modules\Core\Order\DTOs\OrderFulfillmentResult;
|
||||
use Modules\Core\Order\Services\OrderFulfillmentService;
|
||||
use Modules\Core\Order\Services\OrderStatusFlow;
|
||||
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||||
use Modules\Core\Shipping\Support\WeightCalculator;
|
||||
|
||||
/**
|
||||
* Filament wiring only (labels, icons, visibility, form schema) for the
|
||||
* staff-facing status workflow — every guard check, status write, and
|
||||
* event dispatch lives in Modules\Core\Order\Services\
|
||||
* OrderFulfillmentService/OrderStatusFlow, resolved via app() (a
|
||||
* ViewPageExtension is instantiated by Lunar's own extension mechanism,
|
||||
* not the container, so there's no constructor-injection seam here).
|
||||
*
|
||||
* Strips Lunar's own "Update Status" header action (registered by vendor
|
||||
* ManageOrder as Action::make('update_status')) and replaces it with our
|
||||
* own action of the same name — vendor's writes $record->status directly
|
||||
* with no audit trail, no branch validation, and no side effects. Our
|
||||
* replacement offers every status in the order's own branch (carrier or
|
||||
* pickup — see OrderStatusFlow::allOptions()), not just the guided next
|
||||
* step, so staff can freely revert to an earlier status too. It is a
|
||||
* PLAIN status write — picking 'dispatched' here does not create a real
|
||||
* shipment (see "Create Shipment" below for that).
|
||||
*
|
||||
* "Create Shipment" is its own separate header action, visible only for a
|
||||
* carrier order sitting at 'ready_for_dispatch' — this is the one action
|
||||
* that talks to a real carrier API and writes Order::status to
|
||||
* 'dispatched' as a side effect of that succeeding, so it needs its own
|
||||
* weight/locker inputs specific to that one real-world action, not
|
||||
* bundled into the general-purpose status select where they'd appear for
|
||||
* every revert/manual-override use of 'dispatched' too. The form branches
|
||||
* on which carrier the order actually uses
|
||||
* (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS)
|
||||
* gets a single TOTAL weight field for the whole shipment (ACS has no
|
||||
* per-package weight concept — one Weight value is sent alongside
|
||||
* Item_Quantity in the same ACS_Create_Voucher call, see
|
||||
* AcsFulfillmentService::createShipment()), pre-filled from the order's
|
||||
* own line weights (Modules\Core\Shipping\Support\WeightCalculator) but
|
||||
* still staff-editable, plus a package count (ShipmentRequest::
|
||||
* $packageCount) — more than 1 issues a main voucher plus a
|
||||
* multi-part sub-voucher per extra package (persistMultipartVouchers()),
|
||||
* each recorded as its own Shipment row sharing the same total weight in
|
||||
* meta. Box Now, which bills by compartment size rather than
|
||||
* weight, gets a repeatable list of boxes (one row per physical parcel,
|
||||
* each with its own S/M/L size) instead — see
|
||||
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how
|
||||
* multiple boxes become multiple Shipment rows from one delivery request.
|
||||
* Box Now's locker is locked to read-only once the shopper's own checkout
|
||||
* selection ($order->shippingAddress->meta['box_now_locker']) is present
|
||||
* — staff can only fill it in manually for the (current, checkout-UI-less)
|
||||
* case where nothing set it yet.
|
||||
*
|
||||
* "Mark Paid" is a third, separate header action — Order::paid is
|
||||
* independent of `status` (see OrderStatusFlow's own docblock), so it
|
||||
* doesn't belong bundled into the status select either. Visible only when
|
||||
* the order's payment method doesn't auto-capture at checkout (currently
|
||||
* only cash-on-delivery — see OrderStatusFlow::canMarkPaid()); a
|
||||
* processor-managed method (Stripe) or an immediate-capture offline
|
||||
* method (cash-in-hand, bank-transfer) sets Order::paid automatically via
|
||||
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus, so this button
|
||||
* never appears for those.
|
||||
*
|
||||
* Also strips Lunar's own "Download PDF" header action (getDefaultHeaderActions()
|
||||
* in vendor ManageOrder) — its lunarpanel::pdf.order template does not meet
|
||||
* Greek AADE e-invoicing requirements, so it must not be offered as a
|
||||
* downloadable document until a compliant invoice generator exists.
|
||||
*/
|
||||
class OrderViewExtension extends ViewPageExtension
|
||||
{
|
||||
public function headerActions(array $actions): array
|
||||
{
|
||||
$actions = array_filter($actions, fn ($action) => method_exists($action, 'getName')
|
||||
? ! in_array($action->getName(), ['download_pdf', 'update_status'], true)
|
||||
: true);
|
||||
|
||||
$actions[] = $this->createShipmentAction();
|
||||
$actions[] = $this->markPickedUpAction();
|
||||
$actions[] = $this->updateStatusAction();
|
||||
$actions[] = $this->markPaidAction();
|
||||
|
||||
return $actions;
|
||||
}
|
||||
@@ -32,122 +97,133 @@ class OrderViewExtension extends ViewPageExtension
|
||||
->label('Create Shipment')
|
||||
->icon('heroicon-o-truck')
|
||||
->modalSubmitActionLabel('Create Shipment')
|
||||
->schema([
|
||||
TextInput::make('weight')
|
||||
->label('Package weight (kg)')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->helperText('Leave blank to use the carrier\'s default.'),
|
||||
TextInput::make('destination_location_id')
|
||||
->label('Box Now locker ID')
|
||||
->helperText('Only required for Box Now shipments.')
|
||||
->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null),
|
||||
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, array $data, Action $action) {
|
||||
$service = $this->resolveFulfillmentService($record);
|
||||
->schema(function (Order $record) {
|
||||
$isBoxNow = $this->service()->carrierFor($record) === 'box-now';
|
||||
$lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null;
|
||||
|
||||
if (! $service) {
|
||||
Notification::make()
|
||||
->title('No carrier fulfillment integration is configured for this order.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$action->halt();
|
||||
|
||||
return;
|
||||
if (! $isBoxNow) {
|
||||
return [
|
||||
TextInput::make('weight')
|
||||
->label('Total weight (kg)')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(fn () => round(WeightCalculator::totalKg($record->lines), 2) ?: null)
|
||||
->helperText("Calculated from the order's line weights — adjust if needed, or leave blank to use the carrier's default. One figure for the whole shipment, not per package."),
|
||||
TextInput::make('package_count')
|
||||
->label('Number of packages')
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->default(1)
|
||||
->required()
|
||||
->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'),
|
||||
];
|
||||
}
|
||||
|
||||
$request = new ShipmentRequest(
|
||||
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
|
||||
destinationLocationId: $data['destination_location_id'] ?? null,
|
||||
return [
|
||||
TextInput::make('destination_location_id')
|
||||
->label('Box Now locker ID')
|
||||
->default($lockerId)
|
||||
// Locked once the shopper's own checkout selection is
|
||||
// known — staff should not be able to redirect a
|
||||
// parcel to a different locker than the one the
|
||||
// customer picked. Only editable for the (current,
|
||||
// checkout-UI-less) case where nothing set it yet.
|
||||
->disabled(filled($lockerId))
|
||||
->dehydrated()
|
||||
->required()
|
||||
->helperText($lockerId
|
||||
? 'Set by the customer at checkout.'
|
||||
: 'No locker was selected at checkout — enter it manually.'),
|
||||
Repeater::make('boxes')
|
||||
->label('Boxes')
|
||||
->schema([
|
||||
Select::make('size')
|
||||
->label('Size')
|
||||
->options(['S' => 'Small', 'M' => 'Medium', 'L' => 'Large'])
|
||||
->default('S')
|
||||
->native(false)
|
||||
->required(),
|
||||
])
|
||||
->defaultItems(1)
|
||||
->addActionLabel('Add another box')
|
||||
->minItems(1)
|
||||
->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'),
|
||||
];
|
||||
})
|
||||
->action(function (Order $record, array $data, Action $action) {
|
||||
$result = $this->service()->createShipmentAndDispatch(
|
||||
$record,
|
||||
new ShipmentRequest(
|
||||
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
|
||||
packageCount: (int) ($data['package_count'] ?? 1),
|
||||
destinationLocationId: $data['destination_location_id'] ?? null,
|
||||
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
$service->createShipment($record, $request);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title('Failed to create shipment: '.$e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
$this->notify($result);
|
||||
|
||||
if (! $result->success) {
|
||||
$action->halt();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Shipment created.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Order $record) => $record->status === 'ready-for-dispatch'
|
||||
&& ! $record->isStorePickupOrder()
|
||||
&& $record->shipments()->exists() === false
|
||||
&& $this->resolveFulfillmentService($record) !== null);
|
||||
->visible(fn (Order $record) => $this->service()->canCreateShipment($record));
|
||||
}
|
||||
|
||||
/**
|
||||
* The store-pickup mirror of createShipmentAction() — a store-pickup
|
||||
* order never gets a Shipment record (no carrier is ever involved), so
|
||||
* it needs its own way to close out of 'ready-for-pickup' once the
|
||||
* customer has actually collected it. Sets status directly to
|
||||
* 'completed', same terminal status DeriveOrderDeliveredFromShipment
|
||||
* writes for a carrier order once tracking confirms delivery — see
|
||||
* that listener's own docblock.
|
||||
*/
|
||||
private function markPickedUpAction(): Action
|
||||
private function updateStatusAction(): Action
|
||||
{
|
||||
return Action::make('mark_picked_up')
|
||||
->label('Mark Picked Up')
|
||||
->icon('heroicon-o-check-circle')
|
||||
return Action::make('update_status')
|
||||
->label('Update Status')
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->schema(fn (Order $record) => [
|
||||
Select::make('to_status')
|
||||
->label('New status')
|
||||
->options(app(OrderStatusFlow::class)->allOptions($record))
|
||||
->default($record->status)
|
||||
->native(false)
|
||||
->required(),
|
||||
])
|
||||
->action(function (Order $record, array $data, Action $action) {
|
||||
$to = $data['to_status'];
|
||||
$service = $this->service();
|
||||
|
||||
$result = match (true) {
|
||||
($to === 'ready_for_dispatch' || $to === 'ready_for_pickup') && $record->status === 'processing' => $service->markReady($record),
|
||||
$to === 'picked_up' && $record->status === 'ready_for_pickup' => $service->markPickedUp($record),
|
||||
default => $service->transitionTo($record, $to),
|
||||
};
|
||||
|
||||
$this->notify($result);
|
||||
|
||||
if (! $result->success) {
|
||||
$action->halt();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function markPaidAction(): Action
|
||||
{
|
||||
return Action::make('mark_paid')
|
||||
->label('Mark Paid')
|
||||
->icon('heroicon-o-banknotes')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Confirms the customer has collected this order in store.')
|
||||
->action(function (Order $record) {
|
||||
$record->update(['status' => 'completed']);
|
||||
|
||||
Notification::make()
|
||||
->title('Order marked as picked up.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Order $record) => $record->status === 'ready-for-pickup'
|
||||
&& $record->isStorePickupOrder());
|
||||
->modalDescription('Confirms payment for this order has been received outside the system.')
|
||||
->visible(fn (Order $record) => app(OrderStatusFlow::class)->canMarkPaid($record))
|
||||
->action(fn (Order $record) => $this->notify($this->service()->markPaid($record)));
|
||||
}
|
||||
|
||||
private function resolveCarrier(Order $record): ?string
|
||||
private function service(): OrderFulfillmentService
|
||||
{
|
||||
$code = $record->shippingAddress?->shipping_option;
|
||||
|
||||
if (! $code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ShippingMethod::where('code', $code)->value('driver');
|
||||
return app(OrderFulfillmentService::class);
|
||||
}
|
||||
|
||||
private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface
|
||||
private function notify(OrderFulfillmentResult $result): void
|
||||
{
|
||||
$carrier = $this->resolveCarrier($record);
|
||||
|
||||
if (! $carrier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
Notification::make()
|
||||
->title($result->message)
|
||||
->color($result->success ? 'success' : 'danger')
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Pages;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
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 | \BackedEnum | null $navigationIcon = 'heroicon-o-truck';
|
||||
|
||||
protected static ?string $navigationLabel = 'Pickup Manifests';
|
||||
|
||||
/**
|
||||
* Without an explicit group, this page had no navigation group at all —
|
||||
* Filament's Panel::getUrl() falls back to "first item in the first
|
||||
* navigation group" when no homeUrl is set (neither Lunar nor CorePlugin
|
||||
* sets one), and an ungrouped page sorted ahead of every one of Lunar's
|
||||
* own grouped resources (Sales, Catalog, etc.), making this page the
|
||||
* panel's de facto home instead of the real Dashboard. Grouping it under
|
||||
* Sales — alongside CartResource, OrderResource — fixes that by letting
|
||||
* a legitimate item sort first again. Sorted last within the group
|
||||
* deliberately (a high explicit navigationSort — Lunar's own
|
||||
* OrderResource uses 1) so this page never competes to be first even as
|
||||
* more Sales-group items are added later.
|
||||
*/
|
||||
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?int $navigationSort = 100;
|
||||
|
||||
protected 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'),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('print')
|
||||
->label('Print')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Shipment $record) => $this->printShipment($record)),
|
||||
])
|
||||
->toolbarActions([
|
||||
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,66 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ListManifests;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ViewManifest;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers\ShipmentsRelationManager;
|
||||
use Modules\Core\Shipping\Models\Manifest;
|
||||
|
||||
/**
|
||||
* Issued manifests — Modules\Core\Shipping\Models\Manifest is the only
|
||||
* record of "which shipments were on manifest X, and when" this codebase
|
||||
* keeps; ACS's own ACS_Issue_Pickup_List call returns nothing beyond a
|
||||
* reference number, so there is nothing to re-fetch from the carrier
|
||||
* later (see that model's own docblock). Complements
|
||||
* Modules\Core\Shipping\Filament\Resources\ShipmentResource, which only
|
||||
* ever shows shipments NOT YET on a manifest.
|
||||
*/
|
||||
class ManifestResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Manifest::class;
|
||||
|
||||
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||
|
||||
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?string $navigationLabel = 'Issued Manifests';
|
||||
|
||||
protected static ?string $modelLabel = 'Manifest';
|
||||
|
||||
protected static ?int $navigationSort = 101;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('carrier')->badge(),
|
||||
TextColumn::make('reference')->label('Reference')->copyable(),
|
||||
TextColumn::make('shipment_count')->label('Shipments'),
|
||||
TextColumn::make('issued_at')->label('Issued')->dateTime(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
])
|
||||
->defaultSort('issued_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
ShipmentsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListManifests::route('/'),
|
||||
'view' => ViewManifest::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
|
||||
|
||||
class ListManifests extends ListRecords
|
||||
{
|
||||
protected static string $resource = ManifestResource::class;
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
$carriers = collect(Shipping::getSupportedDrivers())
|
||||
->keys()
|
||||
->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
|
||||
|
||||
return $carriers->mapWithKeys(fn (string $carrier) => [
|
||||
$carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier)))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)),
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
|
||||
|
||||
/**
|
||||
* Relation managers (ShipmentsRelationManager) are registered on
|
||||
* ManifestResource::getRelations() — the actual wiring point
|
||||
* (Filament\Resources\Pages\Concerns\HasRelationManagers::
|
||||
* getAllRelationManagers() reads from Resource::getRelations(), not from
|
||||
* an override here). An earlier version of this page overrode
|
||||
* getRelationManagers() directly, bypassing that trait's own
|
||||
* canViewForRecord()/caching logic and causing a broken Livewire
|
||||
* component mount (surfaced as a 419/redirect loop on this exact page).
|
||||
*/
|
||||
class ViewManifest extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ManifestResource::class;
|
||||
|
||||
public function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
TextEntry::make('carrier')->badge(),
|
||||
TextEntry::make('reference')->label('Reference')->copyable(),
|
||||
TextEntry::make('shipment_count')->label('Shipments'),
|
||||
TextEntry::make('issued_at')->label('Issued')->dateTime(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
/**
|
||||
* The shipments a given Manifest actually included — read-only (a
|
||||
* shipment's manifest membership is set once, at issueManifest() time,
|
||||
* never edited here). Reuses ShipmentResource::printShipment() for the
|
||||
* "Print" action rather than duplicating its try/catch-and-notify
|
||||
* handling.
|
||||
*/
|
||||
class ShipmentsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'shipments';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('tracking_reference')->label('Tracking #'),
|
||||
TextColumn::make('order.reference')->label('Order'),
|
||||
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
|
||||
TextColumn::make('cancelled_at')->label('Cancelled')->dateTime()->placeholder('—'),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('print')
|
||||
->label('Print')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Shipment $record) => ShipmentResource::printShipment($record)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Replaces the standalone Modules\Core\Shipping\Filament\Pages\
|
||||
* ManagePickupManifests page — a bare Page has no access to Filament's
|
||||
* resource-level pill-tab UI (Filament\Resources\Concerns\HasTabs is
|
||||
* scoped to ListRecords), so carrier-by-carrier separation
|
||||
* (ListShipments::getTabs(), one tab per SupportsManifestBatching
|
||||
* implementer) needed a real Resource to attach to.
|
||||
*
|
||||
* Shows only shipments NOT yet on an issued manifest — see
|
||||
* Modules\Core\Shipping\Filament\Resources\ManifestResource for
|
||||
* shipments that already are.
|
||||
*/
|
||||
class ShipmentResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Shipment::class;
|
||||
|
||||
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-truck';
|
||||
|
||||
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?string $navigationLabel = 'Pending Vouchers';
|
||||
|
||||
protected static ?string $modelLabel = 'Shipment';
|
||||
|
||||
protected static ?int $navigationSort = 100;
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->whereNull('manifest_id')
|
||||
->whereNull('cancelled_at');
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->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'),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('print')
|
||||
->label('Print')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Shipment $record) => self::printShipment($record)),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkAction::make('print_selected')
|
||||
->label('Print selected')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => self::printShipment($shipment))),
|
||||
BulkAction::make('issue_manifest')
|
||||
->label('Issue Manifest')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->action(fn (Collection $records) => self::issueManifest($records)),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function printShipment(Shipment $shipment): void
|
||||
{
|
||||
$service = self::fulfillmentService($shipment->carrier);
|
||||
|
||||
if (! $service) {
|
||||
Notification::make()
|
||||
->title("No fulfillment integration configured for {$shipment->carrier}.")
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$service->printLabel($shipment);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title("Failed to print label for {$shipment->tracking_reference}: {$e->getMessage()}")
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public static function issueManifest(Collection $shipments): void
|
||||
{
|
||||
$shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
|
||||
$service = self::fulfillmentService($carrier);
|
||||
|
||||
if (! $service instanceof SupportsManifestBatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $service->issueManifest($group);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title("Failed to issue manifest for {$carrier}: {$e->getMessage()}")
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
public static function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
|
||||
{
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShipments::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
|
||||
|
||||
/**
|
||||
* One tab per carrier that actually implements SupportsManifestBatching
|
||||
* (ACS today) — a carrier with no manifest concept at all (Box Now,
|
||||
* which books courier pickup at shipment-creation time, no separate
|
||||
* batching step) never gets a tab here, since there is nothing to batch.
|
||||
* Adding a new carrier (e.g. Speedex) that also implements the contract
|
||||
* needs zero changes to this page — the tab list is derived from
|
||||
* Shipping::getSupportedDrivers(), not hardcoded.
|
||||
*/
|
||||
class ListShipments extends ListRecords
|
||||
{
|
||||
protected static string $resource = ShipmentResource::class;
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
$carriers = collect(Shipping::getSupportedDrivers())
|
||||
->keys()
|
||||
->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
|
||||
|
||||
return $carriers->mapWithKeys(fn (string $carrier) => [
|
||||
$carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier)))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)),
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
/**
|
||||
* Streams a carrier's raw label bytes (CarrierFulfillmentInterface::
|
||||
* printLabel() — already whatever file format the carrier's own API
|
||||
* returns, e.g. a PDF for both ACS and Box Now today) straight to the
|
||||
* browser. Only reachable via a short-lived signed URL (see
|
||||
* Modules\Core\Shipping\Extensions\OrderShipmentsExtension's "Print
|
||||
* Label" action) — the same auth model Lunar's own vendor
|
||||
* DownloadPdfController uses for order PDFs (a valid signature IS the
|
||||
* auth check; there is no separate staff-session check here, matching
|
||||
* that precedent), so the link only works for the few minutes it's
|
||||
* actually open in a browser tab.
|
||||
*
|
||||
* Looks the Shipment up manually from a plain {shipment} id rather than
|
||||
* relying on implicit route-model-binding — this route is registered via
|
||||
* loadRoutesFrom() with no middleware group (see
|
||||
* Modules\Core\Providers\ShippingServiceProvider::boot()), so
|
||||
* SubstituteBindings never runs and a type-hinted Shipment parameter
|
||||
* silently resolves to an empty, non-existent model instead of 404ing.
|
||||
*
|
||||
* Sets label_printed_at as a side effect of a successful stream — this is
|
||||
* the first place in the codebase that actually delivers a label's bytes
|
||||
* to a human; the existing Modules\Core\Shipping\Filament\Pages\
|
||||
* ManagePickupManifests "Print" action calls printLabel() too, but only
|
||||
* to mark the timestamp, discarding the returned bytes entirely (no
|
||||
* download route existed until this one).
|
||||
*/
|
||||
class DownloadShipmentLabelController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, int $shipment)
|
||||
{
|
||||
if (! $request->hasValidSignature()) {
|
||||
abort(401);
|
||||
}
|
||||
|
||||
$shipment = Shipment::findOrFail($shipment);
|
||||
|
||||
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]);
|
||||
|
||||
$bytes = $service->printLabel($shipment);
|
||||
|
||||
return response($bytes, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="shipment-'.$shipment->tracking_reference.'.pdf"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* A carrier pickup-list/manifest as WE recorded it at the moment it was
|
||||
* issued (Modules\Core\Shipping\Contracts\SupportsManifestBatching::
|
||||
* issueManifest()) — the carrier's own API (ACS's ACS_Issue_Pickup_List
|
||||
* included) typically returns nothing beyond a reference number, so this
|
||||
* table is the only place "which shipments were on manifest X, and when"
|
||||
* is ever recorded; it cannot be re-derived from the carrier later.
|
||||
*/
|
||||
class Manifest extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'issued_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function shipments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Shipment::class);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ class Shipment extends Model
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function manifest(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Manifest::class);
|
||||
}
|
||||
|
||||
public function shipmentInfo(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShipmentInfo::class);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Support;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Sums ProductVariant::weight_value/weight_unit across a collection of
|
||||
* Cart/Order lines into a single kg figure — the same conversion table
|
||||
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver::totalWeightInKg()
|
||||
* already used privately for live rate quoting, now shared so
|
||||
* Modules\Core\Shipping\Extensions\OrderViewExtension's "Create Shipment"
|
||||
* weight default can compute the identical figure for an already-placed
|
||||
* Order instead of duplicating the unit table.
|
||||
*/
|
||||
class WeightCalculator
|
||||
{
|
||||
/**
|
||||
* @param Collection $lines Cart::$lines or Order::$lines, each with
|
||||
* its purchasable relation loaded (or loadable — ->load('purchasable')
|
||||
* is called here if not already eager-loaded).
|
||||
*/
|
||||
public static function totalKg(Collection $lines): float
|
||||
{
|
||||
$weight = 0.0;
|
||||
|
||||
foreach ($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 $weight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController;
|
||||
|
||||
Route::get('shipments/{shipment}/label', DownloadShipmentLabelController::class)
|
||||
->name('shipments.label');
|
||||
Reference in New Issue
Block a user