2026-07-19 00:52:01 +03:00
<? php
namespace Modules\Core\Shipping\Extensions ;
2026-08-31 13:16:13 +03:00
use Filament\Actions\Action ;
2026-09-14 00:03:06 +03:00
use Filament\Forms\Components\Repeater ;
use Filament\Forms\Components\Select ;
2026-08-31 13:16:13 +03:00
use Filament\Forms\Components\TextInput ;
2026-07-19 00:52:01 +03:00
use Filament\Notifications\Notification ;
use Lunar\Admin\Support\Extending\ViewPageExtension ;
use Lunar\Models\Order ;
2026-09-14 00:03:06 +03:00
use Modules\Core\Order\DTOs\OrderFulfillmentResult ;
use Modules\Core\Order\Services\OrderFulfillmentService ;
use Modules\Core\Order\Services\OrderStatusFlow ;
2026-09-03 15:58:51 +03:00
use Modules\Core\Shipping\DTOs\ShipmentRequest ;
2026-09-14 00:03:06 +03:00
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.
2026-09-18 00:54:43 +03:00
* Box Now's locker field defaults from the shopper's own checkout
* selection ($order->shippingAddress->meta['box_now_locker']) when present,
* but stays editable — staff can override to a different locker (e.g. the
* customer's choice turns out to be unavailable) or fill it in manually for
* an order placed before the checkout locker picker existed.
2026-09-14 00:03:06 +03:00
*
* "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.
*/
2026-07-19 00:52:01 +03:00
class OrderViewExtension extends ViewPageExtension
{
public function headerActions ( array $actions ) : array
{
2026-09-14 00:03:06 +03:00
$actions = array_filter ( $actions , fn ( $action ) => method_exists ( $action , 'getName' )
? ! in_array ( $action -> getName (), [ 'download_pdf' , 'update_status' ], true )
: true );
2026-07-19 00:52:01 +03:00
$actions [] = $this -> createShipmentAction ();
2026-09-14 00:03:06 +03:00
$actions [] = $this -> updateStatusAction ();
$actions [] = $this -> markPaidAction ();
2026-07-19 00:52:01 +03:00
return $actions ;
}
2026-08-31 13:16:13 +03:00
private function createShipmentAction () : Action
2026-07-19 00:52:01 +03:00
{
2026-08-31 13:16:13 +03:00
return Action :: make ( 'create_shipment' )
2026-07-19 00:52:01 +03:00
-> label ( 'Create Shipment' )
-> icon ( 'heroicon-o-truck' )
-> modalSubmitActionLabel ( 'Create Shipment' )
2026-09-14 00:03:06 +03:00
-> schema ( function ( Order $record ) {
$isBoxNow = $this -> service () -> carrierFor ( $record ) === 'box-now' ;
$lockerId = $record -> shippingAddress ?-> meta [ 'box_now_locker' ][ 'locationId' ] ?? null ;
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.' ),
];
}
return [
TextInput :: make ( 'destination_location_id' )
-> label ( 'Box Now locker ID' )
-> default ( $lockerId )
-> required ()
-> helperText ( $lockerId
2026-09-18 00:54:43 +03:00
? 'Set by the customer at checkout — override if the parcel needs to go to a different locker.'
2026-09-14 00:03:06 +03:00
: '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.' ),
];
})
2026-08-31 13:16:13 +03:00
-> action ( function ( Order $record , array $data , Action $action ) {
2026-09-18 00:54:43 +03:00
// Derived from the order itself, never from staff input —
// whether a shipment collects cash on delivery is a fact
// about the order (which PaymentMethod it was placed
// against), not a choice to make again at dispatch time.
// Previously this was never set at all (defaulted to
// ShipmentRequest::$paymentMode's own null), which silently
// made AcsFulfillmentService::createShipment()'s
// `$request->paymentMode === 'cod'` branch (sends
// Cod_Ammount/Cod_Payment_Way to ACS) permanently
// unreachable, and BoxNowFulfillmentService::createShipment()
// always ship 'prepaid' with amountToBeCollected '0.00' —
// a real COD order would arrive with the carrier believing
// full payment was already settled, and never collect it.
$isCod = app ( OrderStatusFlow :: class ) -> isCod ( $record );
2026-09-14 00:03:06 +03:00
$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 ,
2026-09-18 00:54:43 +03:00
paymentMode : $isCod ? 'cod' : 'prepaid' ,
amountToCollect : $isCod ? $record -> total -> decimal : null ,
2026-09-14 00:03:06 +03:00
boxes : collect ( $data [ 'boxes' ] ?? []) -> pluck ( 'size' ) -> all (),
),
);
2026-07-19 00:52:01 +03:00
2026-09-14 00:03:06 +03:00
$this -> notify ( $result );
2026-07-19 00:52:01 +03:00
2026-09-14 00:03:06 +03:00
if ( ! $result -> success ) {
2026-07-19 00:52:01 +03:00
$action -> halt ();
}
2026-09-14 00:03:06 +03:00
})
-> visible ( fn ( Order $record ) => $this -> service () -> canCreateShipment ( $record ));
}
2026-07-19 00:52:01 +03:00
2026-09-14 00:03:06 +03:00
private function updateStatusAction () : Action
{
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 ();
2026-07-19 18:27:24 +03:00
2026-09-14 00:03:06 +03:00
$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 ),
};
2026-07-19 00:52:01 +03:00
2026-09-14 00:03:06 +03:00
$this -> notify ( $result );
2026-07-19 00:52:01 +03:00
2026-09-14 00:03:06 +03:00
if ( ! $result -> success ) {
2026-07-19 00:52:01 +03:00
$action -> halt ();
}
2026-09-14 00:03:06 +03:00
});
2026-07-19 00:52:01 +03:00
}
2026-09-14 00:03:06 +03:00
private function markPaidAction () : Action
2026-09-10 22:50:40 +03:00
{
2026-09-14 00:03:06 +03:00
return Action :: make ( 'mark_paid' )
-> label ( 'Mark Paid' )
-> icon ( 'heroicon-o-banknotes' )
2026-09-10 22:50:40 +03:00
-> color ( 'success' )
-> requiresConfirmation ()
2026-09-14 00:03:06 +03:00
-> 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 )));
2026-09-10 22:50:40 +03:00
}
2026-09-14 00:03:06 +03:00
private function service () : OrderFulfillmentService
2026-07-19 00:52:01 +03:00
{
2026-09-14 00:03:06 +03:00
return app ( OrderFulfillmentService :: class );
2026-07-19 00:52:01 +03:00
}
2026-09-14 00:03:06 +03:00
private function notify ( OrderFulfillmentResult $result ) : void
2026-07-19 00:52:01 +03:00
{
2026-09-14 00:03:06 +03:00
Notification :: make ()
-> title ( $result -> message )
-> color ( $result -> success ? 'success' : 'danger' )
-> send ();
2026-07-19 00:52:01 +03:00
}
}