feat(shipment): implement status history tracking and rate import improvements

Introduce a shipment status history system to audit status changes,
including the reason for change and the user responsible. This includes
a new `ShipmentStatusHistory` model and a bulk action in the Filament
ShipmentResource to transition statuses.

Enhance the rate import workflow by adding a downloadable Excel
template and refactoring the `ImportRatesPage` to use the modern
Filament form schema.

- Add `ShipmentStatusHistory` model and migration
- Add bulk `changeStatus` action to `ShipmentResource`
- Add `source` field to tracking events
- Implement `ShippingRatesTemplateExport` for rate template downloads
- Refactor `ImportRatesPage` form implementation and UI
- Add `statusHistories` relationship to `Shipment` model
This commit is contained in:
Kazem Alghasi 2026-08-24 03:40:13 +03:30
parent 157ce1bdd4
commit c74924d3aa
9 changed files with 270 additions and 49 deletions

View File

@ -0,0 +1,78 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Concerns\WithTitle;
class ShippingRatesTemplateExport implements WithMultipleSheets
{
use Exportable;
public function sheets(): array
{
return [
new ExportRateSheet('Export Rate'),
new ExportRateSheet('Import Rate'),
new DocEcoSheet(),
];
}
}
class ExportRateSheet implements FromArray, WithTitle
{
public function __construct(protected string $title) {}
public function title(): string
{
return $this->title;
}
public function array(): array
{
$rows = [
['RATE TABLE — ' . strtoupper($this->title)],
['IFNEX Logistics'],
['Weight (kg)', 'Zone 1', 'Zone 2', 'Zone 3', 'Zone 4', 'Zone 5', 'Zone 6', 'Zone 7', 'Zone 8', 'Zone 9', 'Zone 10'],
[''],
];
$weights = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10,
10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15, 15.5, 16, 16.5, 17, 17.5, 18, 18.5, 19, 19.5, 20,
21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100];
foreach ($weights as $w) {
$rows[] = array_merge([$w], array_fill(0, 10, ''));
}
return $rows;
}
}
class DocEcoSheet implements FromArray, WithTitle
{
public function title(): string
{
return 'DocEco';
}
public function array(): array
{
$rows = [
['Weight (kg)', 'Zone', 'Rate (AED)'],
];
$weights = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30, 50];
foreach ($weights as $w) {
$rows[] = [$w, 1, ''];
$rows[] = [$w, 2, ''];
$rows[] = [$w, 3, ''];
}
return $rows;
}
}

View File

@ -2,12 +2,12 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Exports\ShippingRatesTemplateExport;
use App\Imports\ShippingRatesImport; use App\Imports\ShippingRatesImport;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Section;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
@ -17,37 +17,38 @@ class ImportRatesPage extends Page implements HasForms
use InteractsWithForms; use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-arrow-up-tray'; protected static ?string $navigationIcon = 'heroicon-o-arrow-up-tray';
protected static ?string $navigationLabel = 'آپلود نرخ‌ها'; protected static ?string $navigationLabel = 'ایمپورت نرخ‌ها';
protected static ?string $navigationGroup = 'تنظیمات'; protected static ?int $navigationSort = 100;
protected static ?int $navigationSort = 10;
protected static string $view = 'filament.pages.import-rates'; protected static string $view = 'filament.pages.import-rates';
public ?array $data = []; public ?array $formData = [];
public function mount(): void public function mount(): void
{ {
$this->form->fill(); $this->form->fill();
} }
public function getFormSchema(): array public function form(Form $form): Form
{ {
return [ return $form
Section::make('فایل اکسل نرخ‌ها') ->schema([
->description('فایل اکسل حاوی شیت‌های Export Rate, Import Rate و DocEco را آپلود کنید.') FileUpload::make('file')
->schema([ ->label('فایل اکسل نرخ‌ها')
FileUpload::make('file') ->acceptedFileTypes([
->label('فایل اکسل') 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
->acceptedFileTypes([ 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ])
'application/vnd.ms-excel', ->maxSize(5120)
]) ->required()
->maxSize(5120) ->disk('local')
->required() ->directory('imports'),
->disk('local') ])
->directory('imports') ->statePath('formData');
->preserveFilenames(), }
]),
]; public function downloadTemplate(): void
{
(new ShippingRatesTemplateExport)->download('IFNEX_Rate_Template.xlsx');
} }
public function import(): void public function import(): void
@ -75,25 +76,16 @@ class ImportRatesPage extends Page implements HasForms
} }
try { try {
$import = new ShippingRatesImport(); Excel::import(new ShippingRatesImport, $filePath);
Excel::import($import, $filePath);
$stats = [];
foreach ($import->sheets() as $name => $sheet) {
if (method_exists($sheet, 'getStats')) {
$s = $sheet->getStats();
$stats[] = "{$name}: {$s['imported']} imported, {$s['skipped']} skipped";
}
}
Notification::make() Notification::make()
->title('ایمپورت موفق') ->title('موفق')
->body(implode(' | ', $stats)) ->body('نرخ‌ها با موفقیت ایمپورت شدند.')
->success() ->success()
->send(); ->send();
$this->form->fill(); $this->form->fill();
} catch (\Throwable $e) { } catch (\Exception $e) {
Notification::make() Notification::make()
->title('خطا در ایمپورت') ->title('خطا در ایمپورت')
->body($e->getMessage()) ->body($e->getMessage())
@ -101,15 +93,4 @@ class ImportRatesPage extends Page implements HasForms
->send(); ->send();
} }
} }
protected function getFormActions(): array
{
return [
Action::make('import')
->label('شروع ایمپورت')
->icon('heroicon-o-arrow-up-tray')
->color('success')
->action('import'),
];
}
} }

View File

@ -479,6 +479,42 @@ class ShipmentResource extends Resource
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([ Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(), Tables\Actions\DeleteBulkAction::make(),
Action::make('changeStatus')
->label('تغییر وضعیت')
->icon('heroicon-o-arrow-path')
->color('warning')
->form([
Forms\Components\Select::make('new_status')
->label('وضعیت جدید')
->required()
->options(collect(ShipmentStatus::cases())->mapWithKeys(fn ($case) => [
$case->value => $case->label(),
])),
Forms\Components\Textarea::make('reason')
->label('دلیل تغییر')
->rows(2)
->maxLength(500),
])
->action(function (array $data, \Illuminate\Support\Collection $records) {
foreach ($records as $shipment) {
\App\Models\ShipmentStatusHistory::create([
'shipment_id' => $shipment->id,
'from_status' => $shipment->status?->value,
'to_status' => $data['new_status'],
'reason' => $data['reason'] ?? null,
'changed_by' => auth()->id(),
]);
$shipment->update(['status' => $data['new_status']]);
}
\Filament\Notifications\Notification::make()
->title(count($records) . ' محموله بروزرسانی شد')
->success()
->send();
})
->deselectAfterCompletion(),
]), ]),
]); ]);
} }

View File

@ -28,6 +28,14 @@ class TrackingEventsRelationManager extends RelationManager
->default(now()) ->default(now())
->label('Event Time'), ->label('Event Time'),
Forms\Components\TextInput::make('location')->nullable()->label('Location'), Forms\Components\TextInput::make('location')->nullable()->label('Location'),
Forms\Components\Select::make('source')
->options([
'manual' => 'دستی',
'api' => 'API',
'system' => 'سیستم',
])
->default('manual')
->label('منبع'),
Forms\Components\TextInput::make('event_description')->required()->label('Event Description'), Forms\Components\TextInput::make('event_description')->required()->label('Event Description'),
Forms\Components\Select::make('delivery_status') Forms\Components\Select::make('delivery_status')
->options([ ->options([

View File

@ -76,6 +76,11 @@ class Shipment extends Model
return $this->hasMany(ShipmentTrackingEvent::class); return $this->hasMany(ShipmentTrackingEvent::class);
} }
public function statusHistories(): HasMany
{
return $this->hasMany(ShipmentStatusHistory::class)->orderByDesc('created_at');
}
public function isParcel(): bool public function isParcel(): bool
{ {
return $this->type === \App\Enums\ShipmentType::Parcel; return $this->type === \App\Enums\ShipmentType::Parcel;

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ShipmentStatusHistory extends Model
{
protected $fillable = [
'shipment_id', 'from_status', 'to_status', 'reason', 'changed_by',
];
protected static function booted(): void
{
static::creating(function (self $model) {
if (empty($model->changed_by) && auth()->check()) {
$model->changed_by = auth()->id();
}
});
}
public function shipment(): BelongsTo
{
return $this->belongsTo(Shipment::class);
}
public function changedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'changed_by');
}
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipment_status_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('shipment_id')->constrained()->cascadeOnDelete();
$table->string('from_status', 30)->nullable();
$table->string('to_status', 30);
$table->text('reason')->nullable();
$table->foreignId('changed_by')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->index('shipment_id');
});
}
public function down(): void
{
Schema::dropIfExists('shipment_status_histories');
}
};

View File

@ -1,3 +1,53 @@
<x-filament-panels::page> <x-filament-panels::page>
{{ $this->form }} {{ $this->form }}
<div style="margin-top: 16px; display: flex; gap: 8px;">
<a
href="{{ route('download.rate-template') }}"
style="
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #374151;
text-decoration: none;
"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:16px;height:16px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
دانلود template
</a>
<button
type="button"
wire:click="import"
style="
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
background: #10b981;
color: white;
"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:16px;height:16px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
شروع ایمپورت
</button>
</div>
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -39,4 +39,7 @@ Route::get('/payment-result', function (Illuminate\Http\Request $request) {
'amount' => $amount, 'amount' => $amount,
'message' => $message, 'message' => $message,
]); ]);
})->name('payment.result'); })->name('payment.result');
Route::get('/download-rate-template', function () {
return (new \App\Exports\ShippingRatesTemplateExport)->download('IFNEX_Rate_Template.xlsx');
})->middleware(['auth', 'panel:admin'])->name('download.rate-template');