ifnex/04_Laravel/app/Filament/Resources/ShipmentResource.php
Kazem Alghasi 61a2643dd0 feat(api): implement shipment review and resubmission workflow
Introduces a complete shipment review system allowing staff to request
changes to customer orders and customers to resubmit corrected orders.

- Add `ReviewState` enum and `ShipmentReview` model to track review history.
- Implement `ShipmentReviewService` to handle approval and change
  request logic.
- Add `resubmit` endpoint for customers to update orders when
  `changes_requested` state is active.
- Add `request-changes` endpoint for staff to flag orders for correction.
- Update `ShipmentResource` in Filament to display review states and
  manage approvals.
- Implement WordPress bridge support for fetching and resubmitting
  orders via AJAX.
- Add database migrations for `shipment_reviews` table and `review_state`
  column on shipments.
- Add `StaffApiMiddleware` to secure staff-specific API routes.
2026-09-27 01:00:26 +03:30

718 lines
33 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Enums\ShipmentStatus;
use App\Enums\ReviewState;
use App\Services\ShipmentReviewService;
use Filament\Notifications\Notification;
use App\Filament\Resources\ShipmentResource\Pages;
use App\Filament\Resources\ShipmentResource\RelationManagers;
use App\Models\Shipment;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions\Action;
use App\Notifications\ShipmentUpdatedNotification;
class ShipmentResource extends Resource
{
protected static ?string $model = Shipment::class;
protected static ?string $navigationIcon = 'heroicon-o-truck';
protected static ?string $navigationLabel = 'مرسولات';
protected static ?string $navigationGroup = 'عملیات';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Route & Status')
->schema([
Forms\Components\TextInput::make('awb_no')
->required()
->unique(ignoreRecord: true)
->label('AWB No.'),
Forms\Components\Select::make('direction')
->options([
'export' => 'Export',
'import' => 'Import'
])
->required(),
Forms\Components\Select::make('type')
->options([
'DOC_NORMAL' => 'Doc Normal',
'DOC_ECONOMY' => 'Doc Economy',
'PARCEL' => 'Parcel',
])
->required()
->live(),
Forms\Components\Select::make('status')
->options(collect(ShipmentStatus::cases())->mapWithKeys(fn ($case) => [
$case->value => $case->label(),
]))
->default('processed')
->required()
->disabled(fn (?Shipment $record): bool =>
$record !== null &&
$record->review_state !== ReviewState::Approved
)
->dehydrated(),
Forms\Components\TextInput::make('reason_for_export')
->nullable()
->label('Reason for Export'),
Forms\Components\Select::make('from_country_id')
->relationship('fromCountry', 'name')
->searchable()
->preload()
->label('From Country'),
Forms\Components\Select::make('to_country_id')
->relationship('toCountry', 'name')
->searchable()
->preload()
->label('To Country'),
Forms\Components\TextInput::make('forwarder')
->nullable()
->label('Forwarder'),
])->columns(3),
Forms\Components\Section::make('Weight & Dimensions')
->schema([
Forms\Components\TextInput::make('weight')
->numeric()
->nullable()
->suffix('kg')
->label('Weight'),
Forms\Components\TextInput::make('volumetric_weight')
->numeric()
->nullable()
->suffix('kg')
->label('Volumetric Weight'),
Forms\Components\TextInput::make('chargeable_weight')
->numeric()
->nullable()
->suffix('kg')
->label('Chargeable Weight'),
Forms\Components\TextInput::make('dimensions')
->nullable()
->label('Dimensions (WxLxH)'),
Forms\Components\TextInput::make('declared_value')
->numeric()
->nullable()
->prefix('USD')
->label('Declared Value'),
Forms\Components\TextInput::make('content_description')
->nullable()
->label('Content Description'),
])->columns(4),
Forms\Components\Section::make('Packages')
->description('برای محموله‌های چند بسته‌ای، اطلاعات هر بسته را وارد کنید.')
->schema([
Forms\Components\Repeater::make('packages')
->relationship()
->schema([
Forms\Components\TextInput::make('package_no')
->required()
->numeric()
->minValue(1)
->maxValue(99)
->label('#'),
Forms\Components\TextInput::make('weight')
->numeric()
->nullable()
->suffix('kg')
->label('Weight'),
Forms\Components\TextInput::make('volumetric_weight')
->numeric()
->nullable()
->suffix('kg')
->label('Volumetric Wt'),
Forms\Components\TextInput::make('chargeable_weight')
->numeric()
->nullable()
->suffix('kg')
->label('Chargeable Wt'),
Forms\Components\TextInput::make('dimensions')
->nullable()
->label('WxLxH (cm)'),
Forms\Components\TextInput::make('declared_value')
->numeric()
->nullable()
->prefix('USD')
->label('Value'),
Forms\Components\TextInput::make('content_description')
->nullable()
->label('Content'),
])
->columns(4)
->defaultItems(1)
->reorderable()
->label('بسته‌ها'),
])->collapsible(),
Forms\Components\Section::make('Financial Info')
->schema([
Forms\Components\TextInput::make('shipping_price')
->numeric()
->nullable()
->prefix('درهم')
->label('Shipping Price'),
Forms\Components\TextInput::make('extra_service')
->numeric()
->default(0)
->prefix('درهم')
->label('Extra Service'),
Forms\Components\TextInput::make('packing_cost')
->numeric()
->default(0)
->prefix('ریال')
->label('Packing Cost'),
Forms\Components\TextInput::make('domestic_pickup')
->numeric()
->default(0)
->prefix('ریال')
->label('Domestic Pickup'),
Forms\Components\TextInput::make('domestic_delivery')
->numeric()
->default(0)
->prefix('ریال')
->label('Domestic Delivery'),
Forms\Components\TextInput::make('warehousing_cost')
->numeric()
->default(0)
->prefix('ریال')
->label('Warehousing Cost'),
Forms\Components\TextInput::make('vat_amount')
->numeric()
->default(0)
->prefix('ریال')
->label('VAT Amount'),
Forms\Components\TextInput::make('discount')
->numeric()
->default(0)
->prefix('ریال')
->label('Discount'),
Forms\Components\TextInput::make('total_fee')
->numeric()
->nullable()
->prefix('ریال')
->label('Total Fee'),
Forms\Components\TextInput::make('net_dirham')
->numeric()
->nullable()
->prefix('درهم')
->label('Net Dirham'),
Forms\Components\TextInput::make('net_rial')
->numeric()
->nullable()
->prefix('ریال')
->label('Net Rial'),
Forms\Components\TextInput::make('cod_amount')
->numeric()
->default(0)
->prefix('AED')
->label('Cash on Delivery'),
])->columns(4),
Forms\Components\Section::make('Import Invoice Fields (Service Sales Invoice)')
->schema([
Forms\Components\TextInput::make('brand_fee')
->numeric()
->default(0)
->prefix('ریال')
->label('Brand Fee'),
Forms\Components\TextInput::make('report_fee')
->numeric()
->default(0)
->prefix('ریال')
->label('Report Fee'),
Forms\Components\TextInput::make('customs_clearance_cost')
->numeric()
->default(0)
->prefix('ریال')
->label('Customs Clearance Cost'),
Forms\Components\TextInput::make('order_registration_fee')
->numeric()
->default(0)
->prefix('ریال')
->label('Order Registration Fee'),
Forms\Components\TextInput::make('other_clearance_charges')
->numeric()
->default(0)
->prefix('ریال')
->label('Other Clearance Charges'),
Forms\Components\TextInput::make('exchange_rate')
->numeric()
->default(0)
->label('Exchange Rate'),
Forms\Components\TextInput::make('goods_nature')
->nullable()
->label('Goods Nature'),
Forms\Components\Select::make('invoice_currency')
->options([
'USD' => 'USD',
'EUR' => 'EUR',
'AED' => 'AED',
'IRR' => 'IRR',
])
->default('USD')
->label('Invoice Currency'),
])->columns(4),
Forms\Components\Section::make('Sender Info')
->schema([
Forms\Components\TextInput::make('sender_name')
->nullable()
->label('Name'),
Forms\Components\TextInput::make('sender_company')
->nullable()
->label('Company'),
Forms\Components\TextInput::make('sender_phone')
->tel()
->nullable()
->label('Phone'),
Forms\Components\TextInput::make('sender_email')
->email()
->nullable()
->label('Email'),
Forms\Components\TextInput::make('sender_address')
->nullable()
->label('Address'),
Forms\Components\TextInput::make('sender_city')
->nullable()
->label('City'),
Forms\Components\TextInput::make('sender_state')
->nullable()
->label('State/Province'),
Forms\Components\TextInput::make('sender_zip')
->nullable()
->label('ZIP'),
Forms\Components\TextInput::make('sender_id_number')
->nullable()
->label('ID Number'),
])->columns(4),
Forms\Components\Section::make('Receiver Info')
->schema([
Forms\Components\TextInput::make('receiver_name')
->nullable()
->label('Name'),
Forms\Components\TextInput::make('receiver_company')
->nullable()
->label('Company'),
Forms\Components\TextInput::make('receiver_phone')
->tel()
->nullable()
->label('Phone'),
Forms\Components\TextInput::make('receiver_email')
->email()
->nullable()
->label('Email'),
Forms\Components\TextInput::make('receiver_address')
->nullable()
->label('Address'),
Forms\Components\TextInput::make('receiver_city')
->nullable()
->label('City'),
Forms\Components\TextInput::make('receiver_state')
->nullable()
->label('State/Province'),
Forms\Components\TextInput::make('receiver_zip')
->nullable()
->label('ZIP'),
Forms\Components\TextInput::make('receiver_id_number')
->nullable()
->label('ID Number'),
])->columns(4),
Forms\Components\Section::make('Customs Items')
->schema([
Forms\Components\Repeater::make('items')
->relationship()
->schema([
Forms\Components\TextInput::make('row_number')
->required()
->numeric()
->minValue(1)
->maxValue(9)
->label('Row'),
Forms\Components\TextInput::make('description')
->required()
->columnSpan(2)
->label('Description'),
Forms\Components\TextInput::make('hs_code')
->required()
->label('HS Code'),
Forms\Components\TextInput::make('quantity')
->required()
->numeric()
->label('Qty'),
Forms\Components\TextInput::make('unit_price')
->required()
->numeric()
->prefix('USD')
->label('Unit Price'),
Forms\Components\TextInput::make('total_usd')
->required()
->numeric()
->prefix('USD')
->label('Total'),
])
->columns(6)
->maxItems(9)
->defaultItems(1)
->reorderable()
->label('Items'),
]),
Forms\Components\Section::make('Customer Notes')->schema([
Forms\Components\Textarea::make('customer_notes')->nullable()->rows(3)->label('Customer Notes'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('awb_no')
->searchable()->label('AWB No.')->sortable(),
Tables\Columns\TextColumn::make('direction')
->badge()
->color(fn (\App\Enums\ShipmentDirection $state): string => match ($state->value) {
'import' => 'success',
'export' => 'info',
default => 'gray',
})
->label('Direction'),
Tables\Columns\TextColumn::make('type')
->badge()->label('Type'),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (ShipmentStatus $state): string => $state->color())
->formatStateUsing(fn (ShipmentStatus $state): string => $state->label())
->searchable()->label('Status'),
Tables\Columns\TextColumn::make('review_state')
->badge()
->color(fn (ReviewState $state): string => $state->color())
->formatStateUsing(fn (ReviewState $state): string => $state->label())
->searchable()
->sortable()
->label('Review'),
Tables\Columns\TextColumn::make('sender_name')
->searchable()->toggleable()->label('Sender'),
Tables\Columns\TextColumn::make('receiver_name')
->searchable()->toggleable()->label('Receiver'),
Tables\Columns\TextColumn::make('fromCountry.name')
->label('From')
->searchable()
->toggleable()
->sortable(),
Tables\Columns\TextColumn::make('toCountry.name')
->label('To')
->searchable()
->toggleable()
->sortable(),
Tables\Columns\TextColumn::make('route')
->label('Route')
->state(fn ($record) => (
($record->fromCountry?->name ?? '?') .
' → ' .
($record->toCountry?->name ?? '?')
))
->searchable()
->toggleable(),
Tables\Columns\TextColumn::make('sender_phone')
->label('Sender Phone')
->searchable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('receiver_phone')
->label('Receiver Phone')
->searchable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('cod_amount')
->label('COD (AED)')
->money('AED')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('total_fee')
->money('IRR')->sortable()->label('Total Fee'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options(collect(ShipmentStatus::cases())->mapWithKeys(fn ($case) => [
$case->value => $case->label(),
])),
Tables\Filters\SelectFilter::make('type')
->options([
'DOC_NORMAL' => 'Doc Normal',
'DOC_ECONOMY' => 'Doc Economy',
'PARCEL' => 'Parcel',
]),
Tables\Filters\SelectFilter::make('direction')
->options([
'export' => 'Export',
'import' => 'Import'
]),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Action::make('approve')
->label('تأیید')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('تأیید سفارش')
->modalDescription('آیا از تأیید این سفارش اطمینان دارید؟ پس از تأیید، سفارش وارد مرحله پرداخت خواهد شد.')
->modalSubmitActionLabel('بله، تأیید کن')
->visible(fn (Shipment $record): bool =>
$record->status === ShipmentStatus::PendingApproval &&
$record->review_state === ReviewState::Pending
)
->form([
Forms\Components\Textarea::make('notes')
->label('یادداشت بررسی')
->rows(3)
->maxLength(1000)
->nullable(),
])
->action(function (Shipment $record, array $data): void {
try {
app(ShipmentReviewService::class)->approve(
$record,
auth()->user(),
$data['notes'] ?? null
);
Notification::make()
->title('سفارش با موفقیت تأیید شد.')
->body('مشتری اکنون می‌تواند پرداخت سفارش را انجام دهد.')
->success()
->send();
} catch (\RuntimeException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
} catch (\Throwable $e) {
report($e);
Notification::make()
->title('خطا در تأیید سفارش.')
->body('عملیات انجام نشد. لاگ سیستم را بررسی کنید.')
->danger()
->send();
}
}),
Action::make('requestChanges')
->label('درخواست اصلاح')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (Shipment $record): bool =>
$record->status === ShipmentStatus::PendingApproval &&
$record->review_state === ReviewState::Pending
)
->modalHeading('درخواست اصلاح سفارش')
->modalDescription('دلیل اصلاح باید برای مشتری قابل فهم و مشخص باشد.')
->modalSubmitActionLabel('ثبت درخواست اصلاح')
->form([
Forms\Components\Textarea::make('reason')
->label('دلیل اصلاح')
->required()
->rows(4)
->maxLength(1000),
Forms\Components\Textarea::make('notes')
->label('توضیحات تکمیلی')
->rows(4)
->maxLength(1000)
->nullable(),
])
->action(function (Shipment $record, array $data): void {
try {
app(ShipmentReviewService::class)->requestChanges(
$record,
auth()->user(),
$data['reason'],
$data['notes'] ?? null
);
Notification::make()
->title('درخواست اصلاح ثبت شد.')
->body('سفارش اکنون منتظر اصلاح و ارسال مجدد مشتری است.')
->success()
->send();
} catch (\RuntimeException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
} catch (\Throwable $e) {
report($e);
Notification::make()
->title('خطا در ثبت درخواست اصلاح.')
->body('عملیات انجام نشد. لاگ سیستم را بررسی کنید.')
->danger()
->send();
}
}),
Action::make('reject')
->label('رد سفارش')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (Shipment $record): bool =>
$record->status === ShipmentStatus::PendingApproval &&
$record->review_state === ReviewState::Pending
)
->modalHeading('رد سفارش')
->modalDescription('رد سفارش به معنی پایان این چرخه بررسی است و مشتری نمی‌تواند همان سفارش را مجدداً ارسال کند.')
->modalSubmitActionLabel('رد سفارش')
->form([
Forms\Components\Textarea::make('reason')
->label('دلیل رد')
->required()
->rows(4)
->maxLength(1000),
])
->action(function (Shipment $record, array $data): void {
try {
app(ShipmentReviewService::class)->reject(
$record,
auth()->user(),
$data['reason']
);
Notification::make()
->title('سفارش رد شد.')
->success()
->send();
} catch (\RuntimeException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
} catch (\Throwable $e) {
report($e);
Notification::make()
->title('خطا در رد سفارش.')
->body('عملیات انجام نشد. لاگ سیستم را بررسی کنید.')
->danger()
->send();
}
}),
])
->headerActions([
Action::make('exportCsv')
->label('خروجی CSV')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->action(function () {
return response()->streamDownload(function () {
$csv = fopen('php://output', 'w');
// BOM برای پشتیبانی از فارسی در Excel
fwrite($csv, "\xEF\xBB\xBF");
// Header
fputcsv($csv, [
'AWB No',
'Direction',
'Type',
'Status',
'From Country',
'To Country',
'Weight (kg)',
'Chargeable Weight (kg)',
'Shipping Price (AED)',
'Extra Service (AED)',
'Packing Cost (IRR)',
'Total Fee (IRR)',
'Net Rial (IRR)',
'Sender Name',
'Sender Phone',
'Receiver Name',
'Receiver Phone',
'Created At'
]);
// Data
Shipment::query()
->with(['fromCountry', 'toCountry'])
->orderBy('created_at', 'desc')
->chunk(500, function ($shipments) use ($csv) {
foreach ($shipments as $s) {
fputcsv($csv, [
$s->awb_no,
$s->direction?->value ?? '',
$s->type?->value ?? '',
$s->status?->label() ?? '',
$s->fromCountry?->name ?? '',
$s->toCountry?->name ?? '',
$s->weight,
$s->chargeable_weight,
$s->shipping_price,
$s->extra_service,
$s->packing_cost,
$s->total_fee,
$s->net_rial,
$s->sender_name,
$s->sender_phone,
$s->receiver_name,
$s->receiver_phone,
$s->created_at?->format('Y-m-d H:i'),
]);
}
});
fclose($csv);
}, 'shipments_' . now()->format('Y-m-d_H-i') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\CarrierMappingsRelationManager::class,
RelationManagers\TrackingEventsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListShipments::route('/'),
'create' => Pages\CreateShipment::route('/create'),
'view' => Pages\ViewShipment::route('/{record}'),
'edit' => Pages\EditShipment::route('/{record}/edit'),
];
}
}