Feature: Order Updates, Events, Order Flows, Shipment And COD support
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user