feat(commitment-forms): implement end-to-end management and secure file access

Integrate a complete workflow for commitment forms including secure template downloads, customer uploads via WordPress AJAX, and administrative verification with automated notifications.

- Implement `downloadTemplate` and `downloadSigned` logic in `CommitmentFormController` to replace public symlinks with ownership-verified routes.
- Add `CommitmentFormVerifiedNotification` and `CommitmentFormRejectedNotification` to alert customers of status changes.
- Enhance WordPress bridge with AJAX-driven form listing, status badges, and upload capabilities.
- Update Filament `CommitmentFormsRelationManager` to provide better visibility into form directions and descriptions while restricting status edits to specific actions.
- Refactor Laravel 11 bootstrap configuration to handle guest redirection for API and web routes correctly.
- Add administrative controllers for secure file access within the admin panel.
This commit is contained in:
Kazem Alghasi 2026-10-03 05:58:59 +03:30
parent d6f9ce5553
commit 0209b6b32e
10 changed files with 559 additions and 63 deletions

5
.gitignore vendored
View File

@ -17,4 +17,7 @@ Thumbs.db
# هسته لاراول (پکیج‌های دانلودی و تنظیمات)
04_Laravel/vendor/
04_Laravel/node_modules/
04_Laravel/.env
04_Laravel/.env
# سیم‌لینک storage:link — با artisan ساخته می‌شود، نباید کامیت شود
04_Laravel/public/storage/

View File

@ -497,7 +497,104 @@ function ifnex_orders_list_shortcode($atts) {
}
});
});
// 🔹 بارگذاری تعهدنامه‌های سفارش از طریق AJAX
var $cf = $('#ifnex-commitment-forms');
var cfOrderId = $cf.data('order-id');
if (cfOrderId) {
$.post(ifnex_ajax.ajax_url, {
action: 'ifnex_get_commitment_forms',
order_id: cfOrderId,
nonce: ifnex_ajax.nonce
}).done(function(res) {
if (!res || !res.success) {
$cf.html('<p style="color:#dc2626">خطا: ' + ((res && res.data) || 'دریافت نشد') + '</p>');
return;
}
var forms = res.data || [];
if (!forms.length) {
$cf.html('<p style="color:#94a3b8;font-size:13px">تعهدنامه‌ای برای این سفارش ثبت نشده است.</p>');
return;
}
var html = '';
for (var i = 0; i < forms.length; i++) {
var f = forms[i];
var status = f.upload_status || 'pending';
var isUploaded = (status === 'uploaded' || status === 'approved');
var statusLabel = isUploaded ? '✅ آپلود شده' : '⏳ در انتظار آپلود';
var statusClass = isUploaded ? 'ifnex-badge-success' : 'ifnex-badge-warning';
var dlHref = '?order_id=' + cfOrderId + '&download=commitment-template&form_id=' + f.id;
var signedHref = '?order_id=' + cfOrderId + '&download=commitment-signed&form_id=' + f.id;
html += '<div class="ifnex-form-actions" style="border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin-bottom:10px;background:#fff">';
html += '<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">';
html += '<div>';
html += '<strong>' + (f.title || '') + '</strong>';
html += ' <span class="ifnex-badge ' + statusClass + '">' + statusLabel + '</span>';
if (f.file_type) html += ' <span style="color:#94a3b8;font-size:11px">(' + f.file_type + ')</span>';
if (f.description) html += '<div style="color:#64748b;font-size:12px;margin-top:2px">' + f.description + '</div>';
html += '</div>';
html += '<div style="display:flex;gap:8px;flex-wrap:wrap">';
html += '<a href="' + dlHref + '" class="ifnex-btn ifnex-btn-sm ifnex-btn-info" target="_blank">📥 دانلود فرم</a>';
if (isUploaded) {
html += '<a href="' + signedHref + '" class="ifnex-btn ifnex-btn-sm ifnex-btn-success" target="_blank">📄 دانلود امضاشده</a>';
}
html += '<label class="ifnex-btn ifnex-btn-sm ifnex-btn-primary" style="cursor:pointer;margin:0">';
html += '📤 آپلود امضاشده';
html += '<input type="file" accept=".pdf,.jpg,.jpeg,.png" style="display:none" onchange="uploadCommitmentForm(' + cfOrderId + ', ' + f.id + ', this)">';
html += '</label>';
html += '</div>';
html += '</div>';
html += '</div>';
}
$cf.html(html);
}).fail(function() {
$cf.html('<p style="color:#dc2626">خطا در ارتباط با سرور.</p>');
});
}
});
// تعریف روی window چون از onclick صدا زده می‌شود
window.uploadCommitmentForm = function(orderId, formId, input) {
var file = input.files[0];
if (!file) return;
var formData = new FormData();
formData.append('action', 'ifnex_upload_commitment_form');
formData.append('order_id', orderId);
formData.append('form_id', formId);
formData.append('file', file);
formData.append('nonce', ifnex_ajax.nonce);
// جستجوی نزدیک‌ترین container با .ifnex-form-actions (والد دکمه)
var container = input.closest('.ifnex-form-actions') || input.parentElement;
var originalHtml = container.innerHTML;
container.innerHTML = '<span class="ifnex-loading">در حال آپلود...</span>';
jQuery.ajax({
url: ifnex_ajax.ajax_url,
method: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(res) {
if (res.success) {
alert('فایل با موفقیت آپلود شد.');
location.reload();
} else {
alert('خطا: ' + (res.data || 'نامشخص'));
container.innerHTML = originalHtml;
}
},
error: function(xhr) {
var msg = 'خطا در ارتباط با سرور.';
if (xhr && xhr.responseJSON && xhr.responseJSON.data) {
msg = 'خطا: ' + xhr.responseJSON.data;
}
alert(msg);
container.innerHTML = originalHtml;
}
});
};
</script>
<?php
return ob_get_clean();
@ -971,12 +1068,24 @@ function ifnex_order_detail_shortcode($atts) {
return '<p class="ifnex-error">شناسه سفارش مشخص نشده است.</p>';
}
// Handle PDF download
$download_type = sanitize_text_field($_GET['download'] ?? '');
if ($download_type && in_array($download_type, ['awb', 'invoice', 'label', 'import-invoice'])) {
ifnex_handle_pdf_download($order_id, $download_type);
return '';
}
// Handle commitment form downloads (template / signed) — proxied با توکن لاراول
if (in_array($download_type, ['commitment-template', 'commitment-signed'], true)) {
$form_id = intval($_GET['form_id'] ?? 0);
$type = ($download_type === 'commitment-signed') ? 'signed' : 'template';
ifnex_handle_commitment_download($order_id, $form_id, $type);
return '';
}
$bridge = new IFNEX_User_Bridge();
$user_id = get_current_user_id();
@ -1199,7 +1308,9 @@ function ifnex_order_detail_shortcode($atts) {
</div>
<div class="ifnex-od-card">
<h3>📝 تعهدنامه‌ها</h3>
<div id="ifnex-commitment-forms"><p style="color:#94a3b8">در حال بارگذاری...</p></div>
<div id="ifnex-commitment-forms" data-order-id="<?php echo esc_attr($order_id); ?>">
<p style="color:#94a3b8">در حال بارگذاری...</p>
</div>
</div>
</div>
</div>
@ -1239,6 +1350,8 @@ jQuery(document).ready(function($) {
error: function() { alert('خطا در ارتباط با سرور.'); btn.prop('disabled', false).text('❌ لغو'); }
});
});
// بارگذاری تعهدنامه‌ها
$.ajax({
url: ifnex_ajax.ajax_url,
@ -1248,16 +1361,23 @@ jQuery(document).ready(function($) {
if (res.success && res.data && res.data.length > 0) {
var html = '';
res.data.forEach(function(form) {
var badge = form.upload_status === 'uploaded' ? '<span style="color:#10b981">✅ آپلود شده</span>'
: form.upload_status === 'verified' ? '<span style="color:#10b981">✅ تأیید شده</span>'
var badge = form.upload_status === 'verified' ? '<span style="color:#10b981">✅ تأیید شده</span>'
: form.upload_status === 'uploaded' ? '<span style="color:#3b82f6">✅ آپلود شده (در انتظار بررسی)</span>'
: form.upload_status === 'rejected' ? '<span style="color:#ef4444">❌ رد شده</span>'
: '<span style="color:#94a3b8">⏳ آپلود نشده</span>';
html += '<div style="border:1px solid #e2e8f0;border-radius:10px;padding:12px;margin-bottom:8px">';
var reasonHtml = (form.upload_status === 'rejected' && form.notes)
? '<div style="color:#ef4444;font-size:12px;margin-top:4px">⚠️ دلیل رد: ' + form.notes + '</div>'
: '';
// دانلود از طریق پروکسی وردپرس (توکن لاراول سمت سرور ارسال می‌شود؛ لینک مستقیم به API بدون توکن 401/500 می‌دهد)
var dlHref = '?order_id=<?php echo $oid; ?>&download=commitment-template&form_id=' + form.id;
var signedHref = '?order_id=<?php echo $oid; ?>&download=commitment-signed&form_id=' + form.id;
html += '<div class="ifnex-form-actions" style="border:1px solid #e2e8f0;border-radius:10px;padding:12px;margin-bottom:8px">';
html += '<div style="display:flex;justify-content:space-between;align-items:start;gap:8px;flex-wrap:wrap">';
html += '<div><strong>' + form.title + '</strong><br><small style="color:#64748b">' + (form.description || '') + '</small></div>';
html += '<div><strong>' + form.title + '</strong><br><small style="color:#64748b">' + (form.description || '') + '</small>' + reasonHtml + '</div>';
html += badge;
html += '</div><div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap">';
if (form.file_url) html += '<a href="' + form.file_url + '" target="_blank" class="ifnex-btn ifnex-btn-sm ifnex-btn-info">📥 دانلود فرم</a>';
if (form.uploaded_file_url) html += '<a href="' + form.uploaded_file_url + '" target="_blank" class="ifnex-btn ifnex-btn-sm ifnex-btn-success">📎 فایل آپلودشده</a>';
if (form.file_url) html += '<a href="' + dlHref + '" target="_blank" class="ifnex-btn ifnex-btn-sm ifnex-btn-info">📥 دانلود فرم</a>';
if (form.uploaded_file_url) html += '<a href="' + signedHref + '" target="_blank" class="ifnex-btn ifnex-btn-sm ifnex-btn-success">📎 فایل آپلودشده</a>';
html += '<label class="ifnex-btn ifnex-btn-sm ifnex-btn-warning" style="cursor:pointer">📤 آپلود امضاشده<input type="file" accept=".pdf,.jpg,.jpeg,.png" style="display:none" onchange="uploadCommitmentForm(<?php echo $oid; ?>,' + form.id + ',this)"></label>';
html += '</div></div>';
});
@ -1270,17 +1390,17 @@ jQuery(document).ready(function($) {
$('#ifnex-commitment-forms').html('<p style="color:#ef4444;font-size:13px">خطا در بارگذاری تعهدنامه‌ها.</p>');
}
});
});
});
function uploadCommitmentForm(orderId, formId, input) {
// بدون $ — وردپرس jQuery را در حالت noConflict لود می‌کند و $ سراسری وجود ندارد
var file = input.files[0]; if (!file) return;
var fd = new FormData();
fd.append('action', 'ifnex_upload_commitment_form');
fd.append('order_id', orderId); fd.append('form_id', formId);
fd.append('file', file); fd.append('nonce', ifnex_ajax.nonce);
var btn = $(input).closest('.ifnex-form-actions'); var orig = btn.html();
var btn = jQuery(input).closest('.ifnex-form-actions'); var orig = btn.html();
btn.html('<span>در حال آپلود...</span>');
$.ajax({
jQuery.ajax({
url: ifnex_ajax.ajax_url, method: 'POST', data: fd, processData: false, contentType: false,
success: function(r) { if (r.success) { alert('آپلود شد.'); location.reload(); } else { alert('خطا: ' + (r.data||'')); btn.html(orig); } },
error: function() { alert('خطا در ارتباط با سرور.'); btn.html(orig); }
@ -1333,6 +1453,72 @@ function ifnex_handle_pdf_download($order_id, $download_type) {
exit;
}
// ══════════════════════════════════════════════════════════════
// پروکسی دانلود تعهدنامه (قالب خام یا فایل امضاشده) با توکن لاراول
// ══════════════════════════════════════════════════════════════
function ifnex_handle_commitment_download($order_id, $form_id, $type = 'template') {
$user_id = get_current_user_id();
if (!$user_id) wp_die('برای دانلود باید وارد شوید.');
$form_id = intval($form_id);
if (!$form_id) wp_die('شناسه فرم نامعتبر است.');
$bridge = new IFNEX_User_Bridge();
$order = $bridge->get_customer_order($user_id, $order_id);
if (is_wp_error($order)) wp_die('خطا: ' . $order->get_error_message());
$api_url = get_option('ifnex_api_url', 'http://localhost:8000/api/v1');
$endpoint = ($type === 'signed') ? 'download' : 'template';
$download_url = rtrim($api_url, '/') . "/customer/orders/{$order_id}/commitment-forms/{$form_id}/{$endpoint}";
$token = get_user_meta($user_id, 'ifnex_laravel_token', true);
if (!$token) wp_die('توکن احراز هویت یافت نشد.');
$response = wp_remote_get($download_url, array(
'headers' => array(
'Authorization' => 'Bearer ' . $token,
'Accept' => '*/*',
),
'timeout' => 60,
));
if (is_wp_error($response)) wp_die('خطا در ارتباط با سرور.');
$status_code = wp_remote_retrieve_response_code($response);
if ($status_code !== 200) {
$body = wp_remote_retrieve_body($response);
$err = json_decode($body, true);
$msg = $err['message'] ?? ('خطا در دانلود فایل (کد: ' . $status_code . ')');
wp_die(esc_html($msg));
}
$file_content = wp_remote_retrieve_body($response);
$awb = $order['data']['awb_no'] ?? ('order-' . $order_id);
// تشخیص نوع محتوا و نام فایل از روی هدر Laravel
$ctype = wp_remote_retrieve_header($response, 'content-type');
if (empty($ctype)) $ctype = 'application/octet-stream';
$disp = wp_remote_retrieve_header($response, 'content-disposition');
$filename = ($type === 'signed' ? 'signed-' : 'template-') . $form_id . '-' . $awb;
if ($disp && preg_match('/filename="?([^";]+)"?/i', $disp, $m)) {
$filename = trim($m[1]);
} else {
// fallback بر اساس content-type
$ext = (stripos($ctype, 'pdf') !== false) ? 'pdf'
: ((stripos($ctype, 'png') !== false) ? 'png'
: ((stripos($ctype, 'jpeg') !== false) ? 'jpg' : 'bin'));
$filename .= '.' . $ext;
}
nocache_headers();
header('Content-Type: ' . $ctype);
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($file_content));
echo $file_content;
exit;
}
function ifnex_icon($name, $size = 20) {
$icons = [
'dashboard' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75"/>',

View File

@ -2,6 +2,9 @@
namespace App\Filament\Resources\ShipmentResource\RelationManagers;
use App\Models\ShipmentCommitmentForm;
use App\Notifications\CommitmentFormRejectedNotification;
use App\Notifications\CommitmentFormVerifiedNotification;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
@ -18,23 +21,13 @@ class CommitmentFormsRelationManager extends RelationManager
public function form(Form $form): Form
{
// رکوردها فقط از آپلود مشتری یا مقداردهی خودکار ساخته می‌شوند؛
// از این فرم فقط یادداشت قابل ویرایش است (وضعیت با اکشن‌های تأیید/رد تغییر می‌کند)
return $form->schema([
Forms\Components\Select::make('status')
->options([
'pending' => 'در انتظار',
'uploaded' => 'بارگذاری شده',
'verified' => 'تأیید شده',
'rejected' => 'رد شده',
])
->label('وضعیت'),
Forms\Components\Textarea::make('notes')
->rows(3)
->label('یادداشت'),
Forms\Components\Select::make('verified_by')
->relationship('verifier', 'name')
->label('تأییدکننده'),
Forms\Components\DateTimePicker::make('verified_at')
->label('تاریخ تأیید'),
->label('یادداشت')
->maxLength(1000),
]);
}
@ -42,7 +35,21 @@ class CommitmentFormsRelationManager extends RelationManager
{
return $table
->columns([
Tables\Columns\TextColumn::make('form.title')->label('فرم تعهدنامه')->limit(40),
Tables\Columns\TextColumn::make('form.title')
->label('فرم تعهدنامه')
->description(fn ($record) => $record->form?->description)
->limit(40),
Tables\Columns\TextColumn::make('form.direction')
->label('جهت')
->badge()
->color('gray')
->formatStateUsing(fn ($state): string => match ($state) {
'export' => 'صادرات',
'import' => 'واردات',
'both' => 'هردو',
default => (string) $state,
})
->toggleable(),
Tables\Columns\TextColumn::make('status')
->label('وضعیت')
->badge()
@ -54,29 +61,112 @@ class CommitmentFormsRelationManager extends RelationManager
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'pending' => 'در انتظار',
'uploaded' => 'بارگذاری شده',
'pending' => 'در انتظار آپلود',
'uploaded' => 'آپلود شده',
'verified' => 'تأیید شده',
'rejected' => 'رد شده',
default => $state,
}),
Tables\Columns\TextColumn::make('uploader.name')->label('بارگذاری توسط')->toggleable(),
Tables\Columns\TextColumn::make('verifier.name')->label('تأییدکننده')->toggleable(),
Tables\Columns\TextColumn::make('verified_at')->label('تاریخ تأیید')->dateTime('Y/m/d H:i')->toggleable(),
Tables\Columns\TextColumn::make('notes')->label('یادداشت')->limit(40)->toggleable(),
Tables\Columns\TextColumn::make('uploaded_file_type')
->label('فایل مشتری')
->formatStateUsing(function ($state, $record) {
if (!$state) return '—';
$size = $record->uploaded_file_size ? ' · ' . number_format($record->uploaded_file_size / 1024, 0) . ' KB' : '';
return strtoupper($state) . $size;
}),
Tables\Columns\TextColumn::make('created_at')
->label('تاریخ آپلود')
->dateTime('Y/m/d H:i')
->toggleable(),
Tables\Columns\TextColumn::make('uploader.name')
->label('بارگذاری توسط')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('verifier.name')
->label('تأییدکننده')
->toggleable(),
Tables\Columns\TextColumn::make('verified_at')
->label('تاریخ تأیید')
->dateTime('Y/m/d H:i')
->toggleable(),
Tables\Columns\TextColumn::make('notes')
->label('یادداشت / دلیل')
->limit(40)
->toggleable(),
])
->defaultSort('created_at', 'desc')
// ساخت دستی مجاز نیست — این رکوردها از آپلود تعهدنامه توسط مشتری ایجاد می‌شوند
->headerActions([])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('download')
->label('دانلود فایل')
Tables\Actions\Action::make('downloadTemplate')
->label('قالب')
->icon('heroicon-o-arrow-down-tray')
->url(fn ($record) => $record->uploaded_file_path ? asset('storage/' . $record->uploaded_file_path) : null)
->color('gray')
->url(fn ($record) => $record->form?->file_path
? route('admin.commitment-forms.template', ['shipment' => $record->shipment_id, 'form' => $record->commitment_form_id])
: null)
->openUrlInNewTab()
->visible(fn ($record) => !empty($record->uploaded_file_path)),
->visible(fn ($record) => !empty($record->form?->file_path))
->tooltip('دانلود قالب خام تعهدنامه'),
Tables\Actions\Action::make('downloadSigned')
->label('فایل مشتری')
->icon('heroicon-o-document-arrow-down')
->color('info')
->url(fn ($record) => $record->uploaded_file_path
? route('admin.commitment-forms.signed', ['shipment' => $record->shipment_id, 'record' => $record->id])
: null)
->openUrlInNewTab()
->visible(fn ($record) => !empty($record->uploaded_file_path))
->tooltip('دانلود فایل امضاشدهٔ آپلودشده توسط مشتری'),
Tables\Actions\Action::make('verify')
->label('تأیید')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalDescription('با تأیید، به مشتری اعلان ارسال می‌شود.')
->form([
Forms\Components\Textarea::make('notes')
->label('یادداشت برای مشتری (اختیاری)')
->rows(2)
->maxLength(1000),
])
->action(function (ShipmentCommitmentForm $record, array $data): void {
$record->update([
'status' => 'verified',
'notes' => $data['notes'] ?: $record->notes,
'verified_by' => auth()->id(),
'verified_at' => now(),
]);
$record->shipment->user?->notify(new CommitmentFormVerifiedNotification($record));
})
->visible(fn ($record) => in_array($record->status, ['uploaded', 'rejected'])),
Tables\Actions\Action::make('reject')
->label('رد')
->icon('heroicon-o-x-circle')
->color('danger')
->form([
Forms\Components\Textarea::make('notes')
->label('دلیل رد (برای مشتری نمایش داده می‌شود)')
->rows(2)
->required()
->maxLength(1000),
])
->action(function (ShipmentCommitmentForm $record, array $data): void {
$record->update([
'status' => 'rejected',
'notes' => $data['notes'],
'verified_by' => auth()->id(),
'verified_at' => now(),
]);
$record->shipment->user?->notify(new CommitmentFormRejectedNotification($record));
})
->visible(fn ($record) => in_array($record->status, ['uploaded', 'verified'])),
Tables\Actions\EditAction::make()
->label('یادداشت')
->icon('heroicon-o-pencil-square')
->tooltip('ویرایش یادداشت'),
])
->bulkActions([]);
}
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\CommitmentForm;
use App\Models\Shipment;
use App\Models\ShipmentCommitmentForm;
use Illuminate\Support\Facades\Storage;
/**
* دانلود امن فایل‌های تعهدنامه برای پنل ادمین (Filament).
*
* فایل‌های قالب روی دیسک public و فایل‌های امضاشده روی دیسک secure ذخیره می‌شوند؛
* دیسک secure هیچ URL عمومی ندارد و فایل فقط از طریق این روت (با مجوز پنل) استریم می‌شود.
*/
class CommitmentFileController extends Controller
{
/**
* دانلود قالب خام تعهدنامه برای یک سفارش.
* GET /admin/shipments/{shipment}/commitment-forms/{form}/template
*/
public function template(Shipment $shipment, CommitmentForm $form)
{
$this->authorizePanel();
if (!$form->file_path || !Storage::disk('public')->exists($form->file_path)) {
abort(404, 'فایل قالب یافت نشد.');
}
$ext = pathinfo($form->file_path, PATHINFO_EXTENSION);
return Storage::disk('public')->download(
$form->file_path,
'template-' . $form->id . '-' . $shipment->awb_no . '.' . $ext
);
}
/**
* دانلود فایل امضاشده‌ای که مشتری آپلود کرده است.
* GET /admin/shipments/{shipment}/commitment-forms/{record}/signed
*/
public function signed(Shipment $shipment, ShipmentCommitmentForm $record)
{
$this->authorizePanel();
if ($record->shipment_id !== $shipment->id) {
abort(404);
}
if (!$record->uploaded_file_path || !Storage::disk('secure')->exists($record->uploaded_file_path)) {
abort(404, 'فایل امضاشده یافت نشد.');
}
return Storage::disk('secure')->download(
$record->uploaded_file_path,
'commitment-' . $record->commitment_form_id . '-' . $shipment->awb_no . '.' . $record->uploaded_file_type
);
}
/**
* فقط کاربران پنل ادمین (super_admin/admin/staff) مجاز به دانلود هستند.
* همان منطق User::canAccessPanel — بدون نیاز به نمونه پنل (بیرون از کانتکست Filament)
*/
private function authorizePanel(): void
{
abort_unless(auth()->user()?->hasAnyRole(['super_admin', 'admin', 'staff']), 403);
}
}

View File

@ -92,34 +92,82 @@ class CommitmentFormController extends Controller
})
->orderBy('sort_order')
->orderBy('created_at', 'desc')
->get()
->map(function ($form) use ($shipment) {
$upload = ShipmentCommitmentForm::where('shipment_id', $shipment->id)
->where('commitment_form_id', $form->id)
->first();
->get();
return [
'id' => $form->id,
'title' => $form->title,
'description' => $form->description,
'file_url' => $form->file_url,
'file_type' => strtoupper(pathinfo($form->file_path, PATHINFO_EXTENSION)),
'direction' => $form->direction,
'upload_status' => $upload ? $upload->status : 'pending',
'uploaded_file_url' => $upload && $upload->uploaded_file_path
? route('customer.commitment-forms.download', ['shipment' => $shipment->id, 'form' => $form->id])
: null,
'uploaded_at' => $upload ? $upload->created_at : null,
'notes' => $upload ? $upload->notes : null,
];
});
$rows = ShipmentCommitmentForm::where('shipment_id', $shipment->id)
->whereIn('commitment_form_id', $forms->pluck('id'))
->get()
->keyBy('commitment_form_id');
// ساخت ردیف pending برای فرم‌هایی که هنوز رکورد ندارند —
// تا بخش تعهدنامه‌ها در پنل ادمین همه فرم‌ها را همراه وضعیت نمایش دهد
foreach ($forms as $form) {
if (!isset($rows[$form->id])) {
$rows[$form->id] = ShipmentCommitmentForm::create([
'shipment_id' => $shipment->id,
'commitment_form_id' => $form->id,
'status' => 'pending',
]);
}
}
$data = $forms->map(function ($form) use ($shipment, $rows) {
$upload = $rows[$form->id];
return [
'id' => $form->id,
'title' => $form->title,
'description' => $form->description,
// 🛡️ URL دانلود قالب از طریق route محافظت‌شده (نه asset عمومی)
'file_url' => route('customer.commitment-forms.template', [
'shipment' => $shipment->id,
'form' => $form->id,
]),
'file_type' => strtoupper(pathinfo($form->file_path, PATHINFO_EXTENSION)),
'direction' => $form->direction,
'upload_status' => $upload->status,
'uploaded_file_url' => $upload->uploaded_file_path
? route('customer.commitment-forms.download', ['shipment' => $shipment->id, 'form' => $form->id])
: null,
'uploaded_at' => $upload->created_at,
'notes' => $upload->notes,
];
});
return response()->json([
'success' => true,
'data' => $forms,
'data' => $data,
]);
}
/**
* دانلود قالب خام تعهدنامه (محافظت‌شده با بررسی مالکیت سفارش).
* GET /api/v1/customer/orders/{shipment}/commitment-forms/{form}/template
*/
public function downloadTemplate(Shipment $shipment, CommitmentForm $form)
{
$user = auth()->user();
if ($shipment->user_id !== $user->id) {
return response()->json([
'success' => false,
'message' => 'شما به این سفارش دسترسی ندارید.',
], 403);
}
if (!$form->file_path || !Storage::disk('public')->exists($form->file_path)) {
return response()->json([
'success' => false,
'message' => 'فایل قالب یافت نشد.',
], 404);
}
$ext = pathinfo($form->file_path, PATHINFO_EXTENSION);
$filename = 'template-' . $form->id . '-' . $shipment->awb_no . '.' . $ext;
return Storage::disk('public')->download($form->file_path, $filename);
}
/**
* آپلود فرم تعهدنامه امضاشده
* POST /api/v1/customer/orders/{shipment}/commitment-forms/{form}/upload
@ -214,6 +262,4 @@ class CommitmentFormController extends Controller
'commitment-' . $form->id . '-' . $shipment->awb_no . '.' . $upload->uploaded_file_type
);
}
}
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use App\Models\ShipmentCommitmentForm;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
/**
* اعلان به مشتری: تعهدنامهٔ آپلودشدهٔ شما رد شد — باید فایل جدید آپلود کنید.
* (Trigger: CommitmentFormsRelationManager::reject)
*/
class CommitmentFormRejectedNotification extends Notification
{
use Queueable;
public function __construct(
public ShipmentCommitmentForm $record,
) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
$this->record->loadMissing(['shipment', 'form']);
$awb = $this->record->shipment->awb_no;
$title = $this->record->form?->title ?? ('فرم #' . $this->record->commitment_form_id);
$reason = $this->record->notes ? " دلیل: {$this->record->notes}" : '';
return [
'type' => 'commitment_rejected',
'shipment_id' => $this->record->shipment_id,
'awb_no' => $awb,
'message' => "تعهدنامه «{$title}» سفارش {$awb} رد شد. لطفاً فایل اصلاح‌شده را دوباره آپلود کنید.{$reason}",
'action_url' => '/order-detail/?order_id=' . $this->record->shipment_id,
];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Notifications;
use App\Models\ShipmentCommitmentForm;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
/**
* اعلان به مشتری: تعهدنامهٔ آپلودشدهٔ شما تأیید شد.
* (Trigger: CommitmentFormsRelationManager::verify)
*/
class CommitmentFormVerifiedNotification extends Notification
{
use Queueable;
public function __construct(
public ShipmentCommitmentForm $record,
) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
$this->record->loadMissing(['shipment', 'form']);
$awb = $this->record->shipment->awb_no;
$title = $this->record->form?->title ?? ('فرم #' . $this->record->commitment_form_id);
return [
'type' => 'commitment_verified',
'shipment_id' => $this->record->shipment_id,
'awb_no' => $awb,
'message' => "تعهدنامه «{$title}» سفارش {$awb} تأیید شد. ممنون از همکاری شما.",
'action_url' => '/order-detail/?order_id=' . $this->record->shipment_id,
];
}
}

View File

@ -26,6 +26,11 @@ return Application::configure(basePath: dirname(__DIR__))
'api_key' => ApiKeyAuth::class,
]);
// پیش‌فرض لاراول ۱۱ مهمان‌ها را به route('login') می‌فرستد که در این پروژه وجود ندارد
// و باعث 500 می‌شود؛ با null، AuthenticationException به هندلر exceptions می‌رسد
// و برای api/* پاسخ JSON 401 برگردانده می‌شود.
$middleware->redirectGuestsTo(fn () => null);
// Stateful API برای Sanctum
$middleware->statefulApi();
@ -52,5 +57,9 @@ return Application::configure(basePath: dirname(__DIR__))
'error' => 'توکن نامعتبر است یا منقضی شده است. لطفاً دوباره وارد شوید.',
], 401);
}
// مهمان‌های وب (مثلاً بازکردن مستقیم روت‌های دانلود ادمین) به ورود پنل هدایت شوند —
// هندلر پیش‌فرض لاراول به route('login') می‌رود که در این پروژه وجود ندارد
return redirect()->guest(route('filament.admin.auth.login'));
});
})->create();

View File

@ -97,7 +97,10 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
Route::get('/orders/{shipment}/commitment-forms', [CommitmentFormController::class, 'shipmentForms']);
Route::post('/orders/{shipment}/commitment-forms/{form}/upload', [CommitmentFormController::class, 'uploadSigned']);
Route::get('/orders/{shipment}/commitment-forms/{form}/download', [CommitmentFormController::class, 'downloadSigned'])
->name('customer.commitment-forms.download');
->name('customer.commitment-forms.download');
// دانلود قالب خام (محافظت‌شده با auth به جای symlink عمومی)
Route::get('/orders/{shipment}/commitment-forms/{form}/template', [CommitmentFormController::class, 'downloadTemplate'])
->name('customer.commitment-forms.template');
// نوتیفیکیشن‌ها
Route::get('/notifications', [CustomerOrderController::class, 'notifications']);
Route::post('/notifications/{notification}/read', [CustomerOrderController::class, 'markNotificationRead']);

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\CommitmentFileController;
use App\Http\Controllers\OrderController;
use App\Http\Controllers\PricingPageController;
use App\Http\Controllers\ShipmentPdfController;
@ -20,6 +21,12 @@ Route::middleware('auth')->group(function () {
Route::get('/shipments/{shipment}/pdf/invoice', [ShipmentPdfController::class, 'invoice'])->name('shipments.pdf.invoice');
Route::get('/shipments/{shipment}/pdf/label', [ShipmentPdfController::class, 'label'])->name('shipments.pdf.label');
Route::get('/shipments/{shipment}/pdf/import-invoice', [ShipmentPdfController::class, 'importInvoice'])->name('shipments.pdf.import-invoice');
// دانلود امن فایل‌های تعهدنامه برای پنل ادمین (کنترل دسترسی داخل کنترلر با canAccessPanel)
Route::get('/admin/shipments/{shipment}/commitment-forms/{form}/template', [CommitmentFileController::class, 'template'])
->name('admin.commitment-forms.template');
Route::get('/admin/shipments/{shipment}/commitment-forms/{record}/signed', [CommitmentFileController::class, 'signed'])
->name('admin.commitment-forms.signed');
});
// صفحات نتیجه پرداخت