refactor(api): update shipment review logic to update existing reviews

Refactor `ShipmentReviewService` to update the existing pending review
instead of creating a new `ShipmentReview` record during the approval
and rejection processes. This ensures that the review lifecycle
correctly finalizes the current pending revision.

- Implement `getPendingReview` to retrieve the active pending review
- Update `approve` to modify the existing review record
- Update `reject` to modify the existing review record
- Add documentation clarifying when new revisions are created versus
  when existing ones are finalized
This commit is contained in:
Kazem Alghasi 2026-09-29 04:37:55 +03:30
parent 69800a2b1e
commit 7faa778e22
4 changed files with 114 additions and 14 deletions

View File

@ -1 +1 @@
{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.057,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.057,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.012,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.013,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.055,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.1,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.059,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.048,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.047,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.059,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.047,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.081,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.054,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.046,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.065,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.054,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.003,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.008,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.006,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.012,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.005}}
{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.33,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.063,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.044,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.008,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.373,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.429,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.058,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.043,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.517,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.051,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.046,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.083,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.05,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.003,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.008,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.007,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.005}}

View File

@ -15,6 +15,9 @@ class ShipmentReviewService
{
/**
* درخواست اصلاح سفارش توسط کارمند.
*
* این عملیات یک Review Revision جدید ایجاد می‌کند،
* چون یک submission جدید برای بررسی ثبت شده است.
*/
public function requestChanges(
Shipment $shipment,
@ -55,6 +58,9 @@ class ShipmentReviewService
/**
* تأیید سفارش توسط کارمند.
*
* تأیید، Review جدید ایجاد نمی‌کند.
* آخرین revision با وضعیت pending را finalize می‌کند.
*/
public function approve(
Shipment $shipment,
@ -72,8 +78,9 @@ class ShipmentReviewService
'این سفارش در وضعیت قابل تأیید نیست.'
);
$review = $this->getPendingReview($shipment);
$oldStatus = $shipment->status;
$revisionNo = $this->nextReviewRevision($shipment);
$shipment->update([
'status' => ShipmentStatus::Approved,
@ -88,9 +95,7 @@ class ShipmentReviewService
'changed_by' => $user->id,
]);
ShipmentReview::create([
'shipment_id' => $shipment->id,
'revision_no' => $revisionNo,
$review->update([
'decision' => ReviewState::Approved->value,
'reason' => null,
'notes' => $notes,
@ -104,6 +109,9 @@ class ShipmentReviewService
/**
* رد سفارش توسط کارمند.
*
* رد، Review جدید ایجاد نمی‌کند.
* آخرین revision با وضعیت pending را finalize می‌کند.
*/
public function reject(
Shipment $shipment,
@ -121,8 +129,9 @@ class ShipmentReviewService
'این سفارش در وضعیت قابل رد نیست.'
);
$review = $this->getPendingReview($shipment);
$oldStatus = $shipment->status;
$revisionNo = $this->nextReviewRevision($shipment);
$shipment->update([
'status' => ShipmentStatus::Cancelled,
@ -137,9 +146,7 @@ class ShipmentReviewService
'changed_by' => $user->id,
]);
ShipmentReview::create([
'shipment_id' => $shipment->id,
'revision_no' => $revisionNo,
$review->update([
'decision' => ReviewState::Rejected->value,
'reason' => $reason,
'notes' => null,
@ -152,10 +159,7 @@ class ShipmentReviewService
}
/**
* بررسی وضعیت فعلی برای عملیات Review.
*
* این check داخل transaction و بعد از lock انجام می‌شود
* تا race condition بین دو کارمند کاهش یابد.
* بررسی وضعیت فعلی Shipment برای عملیات Review.
*/
private function assertPendingReview(
Shipment $shipment,
@ -170,7 +174,31 @@ class ShipmentReviewService
}
/**
* تعیین revision بعدی.
* پیدا کردن 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
{

View File

@ -0,0 +1,36 @@
{
"direction": "export",
"type": "PARCEL",
"from_country_id": 96,
"to_country_id": 7,
"weight": 3.70,
"dimensions": "90*90*90",
"packages": [
{
"weight": 3.70,
"dimensions": "90*90*90",
"description": "dfasdf"
}
],
"sender_name": "Moshtari Updated",
"sender_phone": "989898989899",
"sender_address": "New addresssss",
"sender_city": "asdfasdf",
"receiver_name": "moshtari2",
"receiver_phone": "565656565",
"receiver_address": "sdfhsdfsdhsdgh",
"receiver_city": "dfgsdfgsdfgs",
"extra_service": 0,
"packing_cost": 0,
"domestic_pickup": 0,
"domestic_delivery": 0,
"warehousing_cost": 0,
"items": [
{
"description": "HHHH25",
"hs_code": "989898989",
"quantity": 12,
"unit_price": 1.20
}
]
}

36
resubmit-test.json Normal file
View File

@ -0,0 +1,36 @@
{
"direction": "export",
"type": "PARCEL",
"from_country_id": 96,
"to_country_id": 7,
"weight": 3.70,
"dimensions": "90*90*90",
"packages": [
{
"weight": 3.70,
"dimensions": "90*90*90",
"description": "dfasdf"
}
],
"sender_name": "Moshtari Updated",
"sender_phone": "989898989899",
"sender_address": "New addresssss",
"sender_city": "asdfasdf",
"receiver_name": "moshtari2",
"receiver_phone": "565656565",
"receiver_address": "sdfhsdfsdhsdgh",
"receiver_city": "dfgsdfgsdfgs",
"extra_service": 0,
"packing_cost": 0,
"domestic_pickup": 0,
"domestic_delivery": 0,
"warehousing_cost": 0,
"items": [
{
"description": "HHHH25",
"hs_code": "989898989",
"quantity": 12,
"unit_price": 1.20
}
]
}