ifnex/04_Laravel/app/Services/ShipmentReviewService.php
Kazem Alghasi 8213ee7ee1 fix(shipment): ensure pending review existence during status transitions
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.
2026-09-30 19:01:27 +03:30

271 lines
8.6 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}