Add logic to automatically create a pending shipment review if one does not exist when transitioning shipment statuses. This ensures the review lifecycle remains intact for shipments created through paths that bypass initial review generation. Also include migrations to make `user_id` and `status` nullable on `shipment_checklists` to support unassigned or incomplete checklists.
271 lines
8.6 KiB
PHP
271 lines
8.6 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\ReviewState;
|
||
use App\Enums\ShipmentStatus;
|
||
use App\Models\Shipment;
|
||
use App\Models\ShipmentReview;
|
||
use App\Models\ShipmentStatusHistory;
|
||
use App\Models\ShipmentChecklistTemplate;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Facades\DB;
|
||
use RuntimeException;
|
||
|
||
class ShipmentReviewService
|
||
{
|
||
/**
|
||
* درخواست اصلاح سفارش توسط کارمند.
|
||
*
|
||
* این عملیات یک Review Revision جدید ایجاد میکند،
|
||
* چون یک submission جدید برای بررسی ثبت شده است.
|
||
*/
|
||
public function requestChanges(
|
||
Shipment $shipment,
|
||
User $user,
|
||
string $reason,
|
||
?string $notes = null
|
||
): Shipment {
|
||
return DB::transaction(function () use ($shipment, $user, $reason, $notes) {
|
||
|
||
$shipment = Shipment::query()
|
||
->lockForUpdate()
|
||
->findOrFail($shipment->id);
|
||
|
||
$this->assertPendingReview(
|
||
$shipment,
|
||
'این سفارش در وضعیت قابل درخواست اصلاح نیست.'
|
||
);
|
||
|
||
$revisionNo = $this->nextReviewRevision($shipment);
|
||
|
||
$shipment->update([
|
||
'review_state' => ReviewState::ChangesRequested,
|
||
]);
|
||
|
||
ShipmentReview::create([
|
||
'shipment_id' => $shipment->id,
|
||
'revision_no' => $revisionNo,
|
||
'decision' => ReviewState::ChangesRequested->value,
|
||
'reason' => $reason,
|
||
'notes' => $notes,
|
||
'reviewed_by' => $user->id,
|
||
'reviewed_at' => now(),
|
||
]);
|
||
|
||
return $shipment->fresh();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* تأیید سفارش توسط کارمند.
|
||
*
|
||
* تأیید، Review جدید ایجاد نمیکند.
|
||
* آخرین revision با وضعیت pending را finalize میکند.
|
||
*/
|
||
public function approve(
|
||
Shipment $shipment,
|
||
User $user,
|
||
?string $notes = null
|
||
): Shipment {
|
||
return DB::transaction(function () use ($shipment, $user, $notes) {
|
||
|
||
$shipment = Shipment::query()
|
||
->lockForUpdate()
|
||
->findOrFail($shipment->id);
|
||
|
||
$this->assertPendingReview(
|
||
$shipment,
|
||
'این سفارش در وضعیت قابل تأیید نیست.'
|
||
);
|
||
|
||
// اگه review pending وجود نداره (سفارش بدون review ایجاد شده)، بساز
|
||
$this->ensurePendingReview($shipment);
|
||
|
||
$review = $this->getPendingReview($shipment);
|
||
|
||
$oldStatus = $shipment->status;
|
||
|
||
$shipment->update([
|
||
'status' => ShipmentStatus::Approved,
|
||
'review_state' => ReviewState::Approved,
|
||
]);
|
||
|
||
ShipmentStatusHistory::create([
|
||
'shipment_id' => $shipment->id,
|
||
'from_status' => $oldStatus->value,
|
||
'to_status' => ShipmentStatus::Approved->value,
|
||
'reason' => $notes ?: 'تأیید توسط کارمند',
|
||
'changed_by' => $user->id,
|
||
]);
|
||
|
||
$review->update([
|
||
'decision' => ReviewState::Approved->value,
|
||
'reason' => null,
|
||
'notes' => $notes,
|
||
'reviewed_by' => $user->id,
|
||
'reviewed_at' => now(),
|
||
]);
|
||
|
||
// خودکارسازی چکلیست از قالبهای فعال (بعد از تأیید سفارش)
|
||
$this->instantiateChecklist($shipment);
|
||
|
||
return $shipment->fresh();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* رد سفارش توسط کارمند.
|
||
*
|
||
* رد، Review جدید ایجاد نمیکند.
|
||
* آخرین revision با وضعیت pending را finalize میکند.
|
||
*/
|
||
public function reject(
|
||
Shipment $shipment,
|
||
User $user,
|
||
string $reason
|
||
): Shipment {
|
||
return DB::transaction(function () use ($shipment, $user, $reason) {
|
||
|
||
$shipment = Shipment::query()
|
||
->lockForUpdate()
|
||
->findOrFail($shipment->id);
|
||
|
||
$this->assertPendingReview(
|
||
$shipment,
|
||
'این سفارش در وضعیت قابل رد نیست.'
|
||
);
|
||
|
||
$this->ensurePendingReview($shipment);
|
||
|
||
$review = $this->getPendingReview($shipment);
|
||
|
||
$oldStatus = $shipment->status;
|
||
|
||
$shipment->update([
|
||
'status' => ShipmentStatus::Cancelled,
|
||
'review_state' => ReviewState::Rejected,
|
||
]);
|
||
|
||
ShipmentStatusHistory::create([
|
||
'shipment_id' => $shipment->id,
|
||
'from_status' => $oldStatus->value,
|
||
'to_status' => ShipmentStatus::Cancelled->value,
|
||
'reason' => 'رد شده: ' . $reason,
|
||
'changed_by' => $user->id,
|
||
]);
|
||
|
||
$review->update([
|
||
'decision' => ReviewState::Rejected->value,
|
||
'reason' => $reason,
|
||
'notes' => null,
|
||
'reviewed_by' => $user->id,
|
||
'reviewed_at' => now(),
|
||
]);
|
||
|
||
return $shipment->fresh();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* بررسی وضعیت فعلی Shipment برای عملیات Review.
|
||
*/
|
||
private function assertPendingReview(
|
||
Shipment $shipment,
|
||
string $message
|
||
): void {
|
||
if (
|
||
$shipment->status !== ShipmentStatus::PendingApproval ||
|
||
$shipment->review_state !== ReviewState::Pending
|
||
) {
|
||
throw new RuntimeException($message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* اگه review pending وجود نداره (سفارش از مسیری بدون ساخت review ایجاد شده)،
|
||
* revision بعدی با decision=pending میسازد تا چرخه تأیید/رد کامل بشه.
|
||
*/
|
||
private function ensurePendingReview(Shipment $shipment): void
|
||
{
|
||
$exists = $shipment->reviews()
|
||
->where('decision', ReviewState::Pending->value)
|
||
->exists();
|
||
|
||
if (!$exists) {
|
||
ShipmentReview::create([
|
||
'shipment_id' => $shipment->id,
|
||
'revision_no' => $this->nextReviewRevision($shipment),
|
||
'decision' => ReviewState::Pending->value,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* پیدا کردن Review فعلی که منتظر تصمیم کارمند است.
|
||
*
|
||
* بعد از resubmit باید دقیقاً یک revision با decision=pending
|
||
* وجود داشته باشد.
|
||
*/
|
||
private function getPendingReview(Shipment $shipment): ShipmentReview
|
||
{
|
||
$review = $shipment->reviews()
|
||
->where('decision', ReviewState::Pending->value)
|
||
->orderByDesc('revision_no')
|
||
->first();
|
||
|
||
if (!$review) {
|
||
throw new RuntimeException(
|
||
'برای این سفارش هیچ Review در وضعیت pending پیدا نشد.'
|
||
);
|
||
}
|
||
|
||
return $review;
|
||
}
|
||
|
||
/**
|
||
* تعیین شماره revision بعدی.
|
||
*
|
||
* هر resubmission یک revision جدید ایجاد میکند.
|
||
*/
|
||
private function nextReviewRevision(Shipment $shipment): int
|
||
{
|
||
return ((int) $shipment->reviews()->max('revision_no')) + 1;
|
||
}
|
||
|
||
/**
|
||
* ساخت instanceهای چکلیست از قالبهای فعال متناسب با جهت سفارش.
|
||
* فقط اگه هنوز چکلیستی برای این سفارش ساخته نشده باشد.
|
||
*/
|
||
private function instantiateChecklist(Shipment $shipment): void
|
||
{
|
||
// جلوگیری از تکرار اگه قبلاً ساخته شده
|
||
if ($shipment->checklists()->exists()) {
|
||
return;
|
||
}
|
||
|
||
$direction = $shipment->direction->value; // 'export' | 'import'
|
||
|
||
$templates = ShipmentChecklistTemplate::query()
|
||
->where('is_active', true)
|
||
->where(function ($q) use ($direction) {
|
||
$q->where('direction', 'both')->orWhere('direction', $direction);
|
||
})
|
||
->orderBy('sort_order')
|
||
->get();
|
||
|
||
if ($templates->isEmpty()) {
|
||
return; // قالب فعالی نیست — چکلیست ساخته نمیشه
|
||
}
|
||
|
||
$rows = $templates->map(fn ($t) => [
|
||
'shipment_id' => $shipment->id,
|
||
'item_title' => $t->item_title,
|
||
'is_required' => $t->is_required,
|
||
'sort_order' => $t->sort_order,
|
||
'is_completed' => false,
|
||
])->toArray();
|
||
|
||
$shipment->checklists()->createMany($rows);
|
||
}
|
||
} |