ifnex/04_Laravel/app/Filament/Resources/ShipmentResource.php
Kazem Alghasi 02c29db696 feat(core): implement order approval flow, credit system, and import invoicing
Introduce a comprehensive set of commercial features including a multi-step
order approval workflow, customer credit management, and specialized
import service invoicing.

Key changes:
- Implement `pending_approval` and `approved` shipment statuses to allow
  staff verification before customer payment.
- Add a credit system to `User` model with `credit_limit` and `credit_used`
  to manage customer balances and debts.
- Develop a new `importInvoice` PDF generation service following the
  "Sheet ENG Invoice" specification for import services.
- Add Filament resources for managing Audit Logs, Commitment Forms,
  Customer Credits, and Shipment Checklists.
- Implement staff-specific APIs for order approval/rejection and
  customer financial status monitoring.
- Integrate Kavenegar SMS service for mobile verification and notifications.
- Add bulk tracking import functionality via CSV/Excel.
- Update WordPress bridge assets (CSS/JS) to support the new multi-step
  order form UI and updated redirection logic.
- Update deployment configurations and documentation to reflect new
  production domains and feature sets.
2026-09-03 06:04:20 +03:30

616 lines
30 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('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('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 ($record) => $record->status === \App\Enums\ShipmentStatus::PendingApproval)
->action(function ($record) {
$oldStatus = $record->status;
$record->update(['status' => \App\Enums\ShipmentStatus::Approved]);
\App\Models\ShipmentStatusHistory::create([
'shipment_id' => $record->id,
'from_status' => $oldStatus->value,
'to_status' => \App\Enums\ShipmentStatus::Approved->value,
'reason' => 'تأیید توسط مدیر از پنل Filament',
'changed_by' => auth()->id(),
]);
\Filament\Notifications\Notification::make()
->title('سفارش با موفقیت تأیید شد')
->success()
->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(),
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'),
];
}
}