ifnex/04_Laravel/app/Filament/Resources/ShipmentResource.php
Kazem Alghasi b35e933475 Phase 3.5 — Core Features Complete:
✅ Multi-Package Support (3.5.1)
- Add shipment_packages migration and ShipmentPackage model
- Add packages() relation to Shipment model
- Update CustomerOrderController to accept packages[] array
- Auto-calculate volumetric weight from dimensions (L*W*H/5000)
- Repeater UI for adding/removing packages
- Real-time summary of total weights

✅ Invoice Form for PARCEL (3.5.2)
- New step in order form (only for PARCEL type)
- Customs items: description, HS Code, quantity, unit price (USD)
- Auto-calculate total per item and invoice total
- Maximum 9 items
- 5-step order form structure

✅ PDF Redesign (3.5.4)
- Install picqer/php-barcode-generator
- Redesign AWB with barcode, From/To, Value, Service, Type
- Redesign Invoice with 9-row table, legal declaration
- Redesign Label to A5 landscape with large barcode
- DOC/PARCEL condition (only PARCEL has invoice)
- Barcode as base64 embed

✅ Technical Fixes (3.5.5)
- Add Enum casts to Shipment model (status, direction, type)
- Fix deselectAfterCompletion -> deselectRecordsAfterCompletion
- Migration for shipment_items (row_number, unit_price)
- Migration to make name nullable
- Fix Enum method names (DocNormal/DocEconomy)

✅ Tracking Test (3.5.6)
- Update TrackController with status_label
- Redesign tracking page with beautiful timeline
- Status box with colored badge
- Timeline with date, description, location, source
- Carrier mappings section

Documentation:
- Update README, Laravel_README, Roadmap, Checklist
- Add IFNEX_Meeting_Summary.md for client presentation
- Update IFNEX_I18N_Strategy.md (moved to deploy phase)

Phase 3.5 — 90% complete (i18n moved to deploy phase)"
2026-08-29 08:35:34 +03:30

545 lines
26 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Enums\ShipmentStatus;
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(),
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('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('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(),
])
->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(),
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();
})
->deselectRecordsAfterCompletion(),
]),
]);
}
public static function afterSave(\Filament\Resources\Pages\Page $page, \Illuminate\Database\Eloquent\Model $record): void
{
if ($page instanceof \Filament\Resources\Pages\EditRecord) {
ShipmentUpdatedNotification::notify($record);
}
}
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'),
];
}
}