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;
|
||||
|
||||
use App\Exports\ShippingRatesTemplateExport;
|
||||
use App\Imports\ShippingRatesImport;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
@ -17,37 +17,38 @@ class ImportRatesPage extends Page implements HasForms
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-arrow-up-tray';
|
||||
protected static ?string $navigationLabel = 'آپلود نرخها';
|
||||
protected static ?string $navigationGroup = 'تنظیمات';
|
||||
protected static ?int $navigationSort = 10;
|
||||
protected static ?string $navigationLabel = 'ایمپورت نرخها';
|
||||
protected static ?int $navigationSort = 100;
|
||||
protected static string $view = 'filament.pages.import-rates';
|
||||
|
||||
public ?array $data = [];
|
||||
public ?array $formData = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill();
|
||||
}
|
||||
|
||||
public function getFormSchema(): array
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return [
|
||||
Section::make('فایل اکسل نرخها')
|
||||
->description('فایل اکسل حاوی شیتهای Export Rate, Import Rate و DocEco را آپلود کنید.')
|
||||
->schema([
|
||||
FileUpload::make('file')
|
||||
->label('فایل اکسل')
|
||||
->acceptedFileTypes([
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
])
|
||||
->maxSize(5120)
|
||||
->required()
|
||||
->disk('local')
|
||||
->directory('imports')
|
||||
->preserveFilenames(),
|
||||
]),
|
||||
];
|
||||
return $form
|
||||
->schema([
|
||||
FileUpload::make('file')
|
||||
->label('فایل اکسل نرخها')
|
||||
->acceptedFileTypes([
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
])
|
||||
->maxSize(5120)
|
||||
->required()
|
||||
->disk('local')
|
||||
->directory('imports'),
|
||||
])
|
||||
->statePath('formData');
|
||||
}
|
||||
|
||||
public function downloadTemplate(): void
|
||||
{
|
||||
(new ShippingRatesTemplateExport)->download('IFNEX_Rate_Template.xlsx');
|
||||
}
|
||||
|
||||
public function import(): void
|
||||
@ -75,25 +76,16 @@ class ImportRatesPage extends Page implements HasForms
|
||||
}
|
||||
|
||||
try {
|
||||
$import = new ShippingRatesImport();
|
||||
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";
|
||||
}
|
||||
}
|
||||
Excel::import(new ShippingRatesImport, $filePath);
|
||||
|
||||
Notification::make()
|
||||
->title('ایمپورت موفق')
|
||||
->body(implode(' | ', $stats))
|
||||
->title('موفق')
|
||||
->body('نرخها با موفقیت ایمپورت شدند.')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->form->fill();
|
||||
} catch (\Throwable $e) {
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('خطا در ایمپورت')
|
||||
->body($e->getMessage())
|
||||
@ -101,15 +93,4 @@ class ImportRatesPage extends Page implements HasForms
|
||||
->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([
|
||||
Tables\Actions\BulkActionGroup::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())
|
||||
->label('Event Time'),
|
||||
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\Select::make('delivery_status')
|
||||
->options([
|
||||
|
||||
@ -76,6 +76,11 @@ class Shipment extends Model
|
||||
return $this->hasMany(ShipmentTrackingEvent::class);
|
||||
}
|
||||
|
||||
public function statusHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShipmentStatusHistory::class)->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public function isParcel(): bool
|
||||
{
|
||||
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>
|
||||
|
||||
{{ $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>
|
||||
@ -39,4 +39,7 @@ Route::get('/payment-result', function (Illuminate\Http\Request $request) {
|
||||
'amount' => $amount,
|
||||
'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