Feature: Order Updates, Events, Order Flows, Shipment And COD support

This commit is contained in:
2026-09-14 00:03:06 +03:00
parent 78bbd8390a
commit 44c6b7defd
73 changed files with 2854 additions and 464 deletions
@@ -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();
}
}
+181 -105
View File
@@ -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();
}
}