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:
parent
157ce1bdd4
commit
c74924d3aa
78
04_Laravel/app/Exports/ShippingRatesTemplateExport.php
Normal file
78
04_Laravel/app/Exports/ShippingRatesTemplateExport.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -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(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
32
04_Laravel/app/Models/ShipmentStatusHistory.php
Normal file
32
04_Laravel/app/Models/ShipmentStatusHistory.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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>
|
||||||
@ -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');
|
||||||
Loading…
Reference in New Issue
Block a user