refactor(core): harden production environment and fix discount logic
Implement critical security hardening and resolve a P0 bug in the discount code consumption flow. This update transitions the system from a development-heavy configuration to a production-ready state. Key changes: - Fix P0 bug where `consumeDiscountCode()` was missing, breaking order registration; replaced with a robust `DiscountCodeService` using `discount_code_usages` for audit trails and idempotency. - Implement strict production guards for the Zarinpal payment gateway to prevent accidental mock processing in live environments. - Refactor WordPress bridge asset management to use `filemtime` based versioning instead of `time()` to prevent unnecessary cache busting. - Remove debug-only hacks (`opcache_reset`, `header` cache control) from the WordPress plugin. - Enhance file upload security by enforcing strict `mimetypes` validation. - Update `.gitignore` to exclude sensitive WordPress configuration files and local PHPUnit caches. - Add comprehensive documentation for production deployment, including security checklists and key rotation procedures. - Update test suite to reflect new architecture and ensure 100% coverage of the discount service.
This commit is contained in:
parent
06601da30c
commit
b9ede16613
5
.gitignore
vendored
5
.gitignore
vendored
@ -30,3 +30,8 @@ Thumbs.db
|
||||
|
||||
# سیملینک storage:link — با artisan ساخته میشود، نباید کامیت شود
|
||||
04_Laravel/public/storage/
|
||||
|
||||
# تنظیمات واقعی وردپرس — حاوی DB credential و saltها است؛ هرگز کامیت نشود
|
||||
# (wp-config-sample.php بهعنوان الگو در مخزن باقی میماند)
|
||||
03_WordPress/wp-config.php
|
||||
04_Laravel/.phpunit.result.cache
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
<?php
|
||||
if (function_exists('opcache_reset')) { @opcache_reset(); }
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
/**
|
||||
* Plugin Name: IFNEX Logistics Bridge
|
||||
* Description: اتصال فرانتاند سایت ایفنکس به API لاراول
|
||||
@ -13,6 +10,26 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── نسخهٔ پلاگین + cache-busting سالم assetها (سختسازی Production) ───
|
||||
// قبلاً اینجا opcache_reset() و header('Cache-Control: no-store') بود که فقط برای دیباگ
|
||||
// لوکال مناسب است و در Production باعث header error و شکستن کش CDN/مرورگر میشد — حذف شد.
|
||||
if ( ! defined( 'IFNEX_BRIDGE_VERSION' ) ) {
|
||||
define( 'IFNEX_BRIDGE_VERSION', '2.1.0' );
|
||||
}
|
||||
|
||||
/**
|
||||
* نسخهٔ asset بر اساس آخرین زمان تغییر فایل.
|
||||
* کش مرورگر فقط وقتی فایل واقعاً عوض شود باطل میشود — جایگزین استاندارد time().
|
||||
*/
|
||||
if ( ! function_exists( 'ifnex_asset_version' ) ) {
|
||||
function ifnex_asset_version( string $relative_path ): string {
|
||||
$file = plugin_dir_path( __FILE__ ) . ltrim( $relative_path, '/' );
|
||||
$mtime = file_exists( $file ) ? (string) filemtime( $file ) : '0';
|
||||
|
||||
return IFNEX_BRIDGE_VERSION . '.' . $mtime;
|
||||
}
|
||||
}
|
||||
|
||||
// لود کردن فایلهای پلاگین
|
||||
require_once plugin_dir_path( __FILE__ ) . 'includes/api-client.php';
|
||||
require_once plugin_dir_path( __FILE__ ) . 'includes/user-bridge.php';
|
||||
@ -34,10 +51,10 @@ function ifnex_enqueue_scripts() {
|
||||
has_shortcode($post->post_content, 'ifnex_order_detail') ||
|
||||
has_shortcode($post->post_content, 'ifnex_order_payment') ||
|
||||
has_shortcode($post->post_content, 'ifnex_customer_dashboard'))) {
|
||||
wp_enqueue_style('ifnex-bridge-css', plugin_dir_url(__FILE__) . 'assets/css/ifnex-bridge.css', array('ifnex-main'), '2.0.0-' . time());
|
||||
wp_enqueue_style('ifnex-bridge-css', plugin_dir_url(__FILE__) . 'assets/css/ifnex-bridge.css', array('ifnex-main'), ifnex_asset_version('assets/css/ifnex-bridge.css'));
|
||||
// SweetAlert2 — پاپآپ خطاها (فاز ۳.۸) — باندل محلی، بدون وابستگی به CDN
|
||||
wp_enqueue_script('sweetalert2', plugin_dir_url(__FILE__) . 'assets/js/sweetalert2.all.min.js', array(), '11.14.5', true);
|
||||
wp_enqueue_script('ifnex-order-form-js', plugin_dir_url(__FILE__) . 'assets/js/ifnex-order-form.js', array('jquery', 'sweetalert2'), '2.0.0-' . time(), true);
|
||||
wp_enqueue_script('ifnex-order-form-js', plugin_dir_url(__FILE__) . 'assets/js/ifnex-order-form.js', array('jquery', 'sweetalert2'), ifnex_asset_version('assets/js/ifnex-order-form.js'), true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
<?php
|
||||
if (function_exists('opcache_reset')) { @opcache_reset(); }
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
/**
|
||||
* IFNEX API Client
|
||||
* اتصال به API لاراول برای استعلام ترکینگ
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
<?php
|
||||
if (function_exists('opcache_reset')) { @opcache_reset(); }
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
/**
|
||||
* IFNEX Shortcodes
|
||||
* شورتکدهای پلاگین IFNEX Bridge
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
<?php
|
||||
if (function_exists('opcache_reset')) { @opcache_reset(); }
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
/**
|
||||
* IFNEX Tracking Form Handler
|
||||
* JavaScript و منطق فرم رهگیری
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
<?php
|
||||
if (function_exists('opcache_reset')) { @opcache_reset(); }
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
/**
|
||||
* IFNEX User Bridge
|
||||
* مدیریت ارتباط کاربران وردپرس با لاراول
|
||||
@ -647,7 +644,7 @@ function ifnex_enqueue_order_assets() {
|
||||
'ifnex-orders-css',
|
||||
$plugin_url . 'assets/css/ifnex-orders.css',
|
||||
array('ifnex-main'),
|
||||
'2.0.0-' . time()
|
||||
ifnex_asset_version('assets/css/ifnex-orders.css')
|
||||
);
|
||||
|
||||
// لود jQuery & JS
|
||||
@ -664,7 +661,7 @@ function ifnex_enqueue_order_assets() {
|
||||
'ifnex-order-form-js',
|
||||
$plugin_url . 'assets/js/ifnex-order-form.js',
|
||||
array('jquery', 'sweetalert2'),
|
||||
'2.0.0-' . time(),
|
||||
ifnex_asset_version('assets/js/ifnex-order-form.js'),
|
||||
true
|
||||
);
|
||||
|
||||
@ -792,7 +789,7 @@ function ifnex_enqueue_payment_assets() {
|
||||
'ifnex-orders-css',
|
||||
$plugin_url . 'assets/css/ifnex-orders.css',
|
||||
array('ifnex-main'),
|
||||
'2.0.0-' . time()
|
||||
ifnex_asset_version('assets/css/ifnex-orders.css')
|
||||
);
|
||||
wp_enqueue_script('jquery');
|
||||
wp_add_inline_script('jquery', 'var ifnex_ajax = ' . wp_json_encode(array(
|
||||
@ -818,7 +815,7 @@ function ifnex_enqueue_global_assets() {
|
||||
'ifnex-orders-css',
|
||||
$plugin_url . 'assets/css/ifnex-orders.css',
|
||||
array('ifnex-main'),
|
||||
'2.0.0-' . time()
|
||||
ifnex_asset_version('assets/css/ifnex-orders.css')
|
||||
);
|
||||
|
||||
// لود jQuery
|
||||
|
||||
@ -20,6 +20,20 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# ─── 🛡️ سختسازی Production ─────────────────────────────────────
|
||||
# روی سرور واقعی این مقادیر را در .env تنظیم کنید (این مقادیر فقط برای Production است):
|
||||
#
|
||||
# APP_ENV=production
|
||||
# APP_DEBUG=false
|
||||
# LOG_STACK=daily # فایل لاگ روزانه
|
||||
# LOG_LEVEL=warning
|
||||
# LOG_DAILY_DAYS=14 # نگهداری ۱۴ روز لاگ
|
||||
#
|
||||
# و بعد از دیپلوی (کش برای سرعت):
|
||||
# php artisan config:cache && php artisan route:cache && php artisan view:cache
|
||||
# php artisan storage:link
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
@ -65,12 +79,17 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
IFNEX_API_KEY=
|
||||
# ⚠️ کلید Bridge باید عیناً در تنظیمات پلاگین وردپرس هم باشد.
|
||||
# پیش از دیپلوی با رشتهٔ تصادفی جدید (مثلاً خروجی: php -r "echo bin2hex(random_bytes(32));") چرخش دهید.
|
||||
IFNEX_BRIDGE_API_KEY=
|
||||
CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1
|
||||
IFNEX_TRACKING_RATE_LIMIT=60
|
||||
CURRENCY_API_KEY=
|
||||
|
||||
|
||||
# Zarinpal Payment Gateway
|
||||
# ⚠️ سختسازی: در Production هرگز ZARINPAL_MERCHANT_ID=fake... و ZARINPAL_SANDBOX=true نگذارید.
|
||||
# کد اکنون در APP_ENV=production اگر درگاه تستی تنظیم باشد، با خطای صریح از پردازش جلوگیری میکند.
|
||||
ZARINPAL_MERCHANT_ID=fake-merchant-id-for-testing
|
||||
ZARINPAL_SANDBOX=true
|
||||
ZARINPAL_CALLBACK_URL=http://localhost:8000/api/v1/payment/callback
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* وقتی مصرف کد تخفیف ناموفق است پرتاب میشود:
|
||||
* کد ناموجود / غیرفعال / منقضی / تمامشده ظرفیت.
|
||||
*
|
||||
* در کنترلر ثبت سفارش، این اکسپشن باید قبل از \Exception گرفته شود
|
||||
* تا پاسخ 422 (نه 500) به مشتری برگردد — سفارش با قیمت اشتباه ثبت نمیشود.
|
||||
*/
|
||||
class DiscountCodeUnavailableException extends RuntimeException
|
||||
{
|
||||
//
|
||||
}
|
||||
@ -185,7 +185,14 @@ class CommitmentFormController extends Controller
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:5120', // حداکثر 5MB
|
||||
// سختسازی Production: mimetypes محتوا را واقعاً بررسی میکند (نه فقط پسوند)
|
||||
'file' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimes:pdf,jpg,jpeg,png',
|
||||
'mimetypes:application/pdf,image/jpeg,image/png',
|
||||
'max:5120', // حداکثر 5MB
|
||||
],
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
|
||||
@ -20,11 +20,14 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Services\DiscountCodeService;
|
||||
use App\Exceptions\DiscountCodeUnavailableException;
|
||||
|
||||
class CustomerOrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PriceCalculatorService $calculator
|
||||
protected PriceCalculatorService $calculator,
|
||||
protected DiscountCodeService $discountCodeService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -308,9 +311,15 @@ class CustomerOrderController extends Controller
|
||||
]);
|
||||
}
|
||||
}
|
||||
$this->calculator->consumeDiscountCode(
|
||||
$priceResult['discount_code'] ?? null
|
||||
// مصرف کد تخفیف (سختسازی Production) — داخل همین transaction
|
||||
// شکست مصرف = rollback سفارش تا با قیمت تخفیفدارِ نامعتبر ثبت نشود
|
||||
$this->discountCodeService->consumeForOrder(
|
||||
code: $priceResult['discount_code'] ?? null,
|
||||
user: $user,
|
||||
shipment: $shipment,
|
||||
discountedAmount: (float) ($priceResult['discount_amount'] ?? 0)
|
||||
);
|
||||
|
||||
return $shipment;
|
||||
});
|
||||
|
||||
@ -321,6 +330,12 @@ class CustomerOrderController extends Controller
|
||||
'pricing' => $priceResult,
|
||||
], 201);
|
||||
|
||||
} catch (DiscountCodeUnavailableException $e) {
|
||||
// کد تخفیف بین محاسبهٔ قیمت و ثبت منقضی/پر شد — 422 تا کاربر با قیمت تازه ثبت کند
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
@ -636,9 +651,9 @@ class CustomerOrderController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$this->calculator->consumeDiscountCode(
|
||||
$priceResult['discount_code'] ?? null
|
||||
);
|
||||
// نکته (سختسازی Production): کد تخفیف فقط هنگام ثبت اولیهٔ سفارش مصرف
|
||||
// میشود؛ در ارسال مجدد همان سفارش، مصرف دوباره رخ نمیدهد (Idempotency).
|
||||
// قیمتگذاری مجدد با وضعیت فعلی کد انجام میشود.
|
||||
|
||||
ShipmentReview::create([
|
||||
'shipment_id' => $shipment->id,
|
||||
|
||||
@ -26,8 +26,22 @@ class PaymentController extends Controller
|
||||
|
||||
private function isMockMode(): bool
|
||||
{
|
||||
return config('ifnex.zarinpal.merchant_id') === 'fake-merchant-id-for-testing'
|
||||
$isMock = config('ifnex.zarinpal.merchant_id') === 'fake-merchant-id-for-testing'
|
||||
|| config('ifnex.zarinpal.sandbox', true);
|
||||
|
||||
// 🛡️ سختسازی Production: درگاه تستی هرگز نباید در Production پردازش کند.
|
||||
// اگر ENV=production و تنظیمات درگاه به حالت تستی باشد، بهجای پردازشِ
|
||||
// بیصدا با پول مجازی، خطای واضح پرتاب میشود تا پیکربندی اصلاح شود.
|
||||
if ($isMock && app()->environment('production')) {
|
||||
Log::critical('MOCK payment gateway blocked in production — fix ZARINPAL_MERCHANT_ID / ZARINPAL_SANDBOX immediately');
|
||||
|
||||
throw new \RuntimeException(
|
||||
'درگاه پرداخت تستی (Mock) در محیط Production مجاز نیست. '
|
||||
.'ZARINPAL_MERCHANT_ID واقعی و ZARINPAL_SANDBOX=false را در .env تنظیم کنید.'
|
||||
);
|
||||
}
|
||||
|
||||
return $isMock;
|
||||
}
|
||||
|
||||
private function getGatewayService()
|
||||
|
||||
38
04_Laravel/app/Models/DiscountCodeUsage.php
Normal file
38
04_Laravel/app/Models/DiscountCodeUsage.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* رکورد مصرف کد تخفیف (Audit).
|
||||
*
|
||||
* هر مصرف موفق یک کد تخفیف روی یک سفارش، یک رکورد در این جدول ثبت میکند.
|
||||
* ایندکس یکتا روی (discount_code_id, shipment_id) مصرف دوباره را روی همان
|
||||
* سفارش در سطح دیتابیس هم مسدود میکند.
|
||||
*/
|
||||
class DiscountCodeUsage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'discount_code_id',
|
||||
'user_id',
|
||||
'shipment_id',
|
||||
'discounted_amount',
|
||||
];
|
||||
|
||||
public function discountCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DiscountCode::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function shipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Shipment::class);
|
||||
}
|
||||
}
|
||||
135
04_Laravel/app/Services/DiscountCodeService.php
Normal file
135
04_Laravel/app/Services/DiscountCodeService.php
Normal file
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\DiscountCodeUnavailableException;
|
||||
use App\Models\DiscountCode;
|
||||
use App\Models\DiscountCodeUsage;
|
||||
use App\Models\Shipment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* سرویس مصرف کد تخفیف (سختسازی Production — فاز ۳.۸.۱)
|
||||
*
|
||||
* قواعد:
|
||||
* ۱. مصرف کد فقط در لحظهٔ ثبت سفارش (store) انجام میشود؛
|
||||
* در ارسال مجدد (resubmit) مصرف دوباره رخ نمیدهد.
|
||||
* ۲. با lockForUpdate + شرط atomic روی used_count، race condition
|
||||
* بین دو ثبت همزمان بسته میشود (ظرفیت هرگز منفی/بیش از حد نمیشود).
|
||||
* ۳. هر مصرف موفق یک رکورد Audit در discount_code_usages ثبت میکند.
|
||||
* ۴. اگر کد در محاسبهٔ قیمت اعمال شده ولی در مصرف شکست بخورد،
|
||||
* اکسپشن پرتاب میشود تا سفارش با قیمت اشتباه ثبت نشود (حمایت مالی).
|
||||
*/
|
||||
class DiscountCodeService
|
||||
{
|
||||
/**
|
||||
* مصرف یک کد تخفیف برای سفارش دادهشده.
|
||||
*
|
||||
* @param string|null $code کد تخفیف (null یعنی مشتری کد نداده — بدون خطا)
|
||||
* @param User $user کاربر ثبتکنندهٔ سفارش
|
||||
* @param Shipment $shipment سفارش تازهساختهشده (داخل همان transaction)
|
||||
* @param float $discountedAmount مبلغ تخفیفی که در محاسبهٔ قیمت اعمال شد (ریال)
|
||||
*
|
||||
* @return bool true یعنی مصرف شد (یا قبلاً برای همین سفارش مصرف شده بود — Idempotent)
|
||||
*
|
||||
* @throws DiscountCodeUnavailableException وقتی کد معتبر نیست یا ظرفیتش تمام شده
|
||||
*/
|
||||
public function consumeForOrder(
|
||||
?string $code,
|
||||
User $user,
|
||||
Shipment $shipment,
|
||||
float $discountedAmount = 0.0
|
||||
): bool {
|
||||
$code = trim((string) $code);
|
||||
|
||||
// مشتری اصلاً کد نداده — چیزی برای مصرف نیست
|
||||
if ($code === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// جستجوی کد با قفل ردیفی (داخل transaction فراخوان باید باشد)
|
||||
$discount = DiscountCode::where('code', $code)
|
||||
->where('is_active', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$discount) {
|
||||
throw new DiscountCodeUnavailableException(
|
||||
'کد تخفیف معتبر نیست یا منقضی شده است. سفارش بدون تخفیف مجدداً ثبت شود.'
|
||||
);
|
||||
}
|
||||
|
||||
// Idempotency: اگر همین کد قبلاً برای همین سفارش مصرف شده، دوباره مصرف نکن
|
||||
$alreadyUsed = DiscountCodeUsage::where('discount_code_id', $discount->id)
|
||||
->where('shipment_id', $shipment->id)
|
||||
->exists();
|
||||
|
||||
if ($alreadyUsed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// افزایش atomic با گارد ظرفیت — حتی اگر دو درخواست همزمان برسند
|
||||
// فقط یکی موفق میشود وقتی ظرفیت تکمصرف است
|
||||
$updated = DiscountCode::where('id', $discount->id)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('usage_limit')
|
||||
->orWhereColumn('used_count', '<', 'usage_limit');
|
||||
})
|
||||
->increment('used_count');
|
||||
|
||||
if (!$updated) {
|
||||
throw new DiscountCodeUnavailableException(
|
||||
'ظرفیت استفاده از این کد تخفیف به پایان رسیده است. سفارش بدون تخفیف مجدداً ثبت شود.'
|
||||
);
|
||||
}
|
||||
|
||||
// رکورد Audit
|
||||
DiscountCodeUsage::create([
|
||||
'discount_code_id' => $discount->id,
|
||||
'user_id' => $user->id,
|
||||
'shipment_id' => $shipment->id,
|
||||
'discounted_amount' => round($discountedAmount, 2),
|
||||
]);
|
||||
|
||||
Log::info('Discount code consumed', [
|
||||
'code' => $discount->code,
|
||||
'discount_code_id' => $discount->id,
|
||||
'user_id' => $user->id,
|
||||
'shipment_id' => $shipment->id,
|
||||
'discounted_amount' => round($discountedAmount, 2),
|
||||
'used_count' => $discount->used_count + 1,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* مصرف ایمن — خطا را لاگ میکند و بهجای پرتاب، false برمیگرداند.
|
||||
* برای سناریوهایی که شکست مصرف نباید ثبت سفارش را قطع کند.
|
||||
*/
|
||||
public function tryConsumeForOrder(
|
||||
?string $code,
|
||||
User $user,
|
||||
Shipment $shipment,
|
||||
float $discountedAmount = 0.0
|
||||
): bool {
|
||||
try {
|
||||
return $this->consumeForOrder($code, $user, $shipment, $discountedAmount);
|
||||
} catch (DiscountCodeUnavailableException $e) {
|
||||
Log::warning('Discount code consumption failed', [
|
||||
'code' => $code,
|
||||
'user_id' => $user->id,
|
||||
'shipment_id' => $shipment->id,
|
||||
'reason' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -220,6 +220,11 @@ class PriceCalculatorService
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
// سختسازی Production: کدهایی که ظرفیتشان پر شده دیگر در قیمتگذاری اعمال نمیشوند
|
||||
->where(function ($q) {
|
||||
$q->whereNull('usage_limit')
|
||||
->orWhereColumn('used_count', '<', 'usage_limit');
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($discount) {
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* جدول مصرف کد تخفیف (سختسازی Production — فاز ۳.۸.۱)
|
||||
*
|
||||
* هر بار که یک کد تخفیف روی سفارش مصرف شود، یک رکورد اینجا ثبت میشود.
|
||||
* این جدول ابزار Audit است: مشخص میکند چه کدی، توسط چه کاربری، روی چه سفارشی
|
||||
* و با چه مبلغ تخفیفی مصرف شده است.
|
||||
*
|
||||
* ایندکس یکتا (discount_code_id, shipment_id) مانع مصرف دوبارهٔ یک کد
|
||||
* روی همان سفارش میشود (Idempotency).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('discount_code_usages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('discount_code_id')->constrained('discount_codes')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignId('shipment_id')->nullable()->constrained('shipments')->nullOnDelete();
|
||||
$table->decimal('discounted_amount', 16, 2)->default(0); // مبلغ تخفیف اعمالشده (ریال)
|
||||
$table->timestamps();
|
||||
|
||||
// یک کد فقط یکبار روی هر سفارش مصرف میشود
|
||||
$table->unique(['discount_code_id', 'shipment_id'], 'discount_usage_code_shipment_unique');
|
||||
|
||||
$table->index(['user_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('discount_code_usages');
|
||||
}
|
||||
};
|
||||
@ -3,7 +3,9 @@
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\DiscountCode;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
@ -11,6 +13,17 @@ class DiscountCodeControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// روتهای کد تخفیف داخل گروه auth:sanctum هستند (سختسازی Production)
|
||||
$this->user = User::factory()->create();
|
||||
Sanctum::actingAs($this->user);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_returns_active_discount_codes()
|
||||
{
|
||||
|
||||
184
04_Laravel/tests/Feature/Services/DiscountCodeServiceTest.php
Normal file
184
04_Laravel/tests/Feature/Services/DiscountCodeServiceTest.php
Normal file
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Services;
|
||||
|
||||
use App\Exceptions\DiscountCodeUnavailableException;
|
||||
use App\Models\DiscountCode;
|
||||
use App\Models\DiscountCodeUsage;
|
||||
use App\Models\Shipment;
|
||||
use App\Models\User;
|
||||
use App\Services\DiscountCodeService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* تستهای سرویس مصرف کد تخفیف (سختسازی Production — فاز ۳.۸.۱)
|
||||
*/
|
||||
class DiscountCodeServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private DiscountCodeService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->service = app(DiscountCodeService::class);
|
||||
}
|
||||
|
||||
private function makeShipment(): Shipment
|
||||
{
|
||||
return Shipment::create([
|
||||
'user_id' => $this->user->id,
|
||||
'awb_no' => 'IFN-2026-' . str_pad((string) random_int(1, 99999), 5, '0', STR_PAD_LEFT),
|
||||
'direction' => 'export',
|
||||
'type' => 'DOC_NORMAL',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_consumes_code_and_increments_used_count()
|
||||
{
|
||||
$code = DiscountCode::create([
|
||||
'code' => 'SAVE10',
|
||||
'type' => 'percentage',
|
||||
'value' => 10,
|
||||
'usage_limit' => 5,
|
||||
'used_count' => 0,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$shipment = $this->makeShipment();
|
||||
|
||||
$result = DB::transaction(fn () => $this->service->consumeForOrder(
|
||||
'SAVE10',
|
||||
$this->user,
|
||||
$shipment,
|
||||
discountedAmount: 50000
|
||||
));
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertEquals(1, $code->fresh()->used_count);
|
||||
|
||||
// رکورد Audit ثبت شده است
|
||||
$usage = DiscountCodeUsage::where('discount_code_id', $code->id)->first();
|
||||
$this->assertNotNull($usage);
|
||||
$this->assertEquals($this->user->id, $usage->user_id);
|
||||
$this->assertEquals($shipment->id, $usage->shipment_id);
|
||||
$this->assertEquals(50000, (float) $usage->discounted_amount);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_is_idempotent_for_the_same_shipment()
|
||||
{
|
||||
$code = DiscountCode::create([
|
||||
'code' => 'ONCE',
|
||||
'type' => 'fixed',
|
||||
'value' => 10000,
|
||||
'usage_limit' => 5,
|
||||
'used_count' => 0,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$shipment = $this->makeShipment();
|
||||
|
||||
DB::transaction(fn () => $this->service->consumeForOrder('ONCE', $this->user, $shipment));
|
||||
DB::transaction(fn () => $this->service->consumeForOrder('ONCE', $this->user, $shipment));
|
||||
|
||||
// مصرف دوباره روی همان سفارش نباید شمارنده را بالا ببرد (Idempotency)
|
||||
$this->assertEquals(1, $code->fresh()->used_count);
|
||||
$this->assertEquals(1, DiscountCodeUsage::where('discount_code_id', $code->id)->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_throws_when_usage_limit_is_reached()
|
||||
{
|
||||
DiscountCode::create([
|
||||
'code' => 'LIMIT2',
|
||||
'type' => 'fixed',
|
||||
'value' => 10000,
|
||||
'usage_limit' => 2,
|
||||
'used_count' => 2, // ظرفیت پر شده
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$shipment = $this->makeShipment();
|
||||
|
||||
$this->expectException(DiscountCodeUnavailableException::class);
|
||||
$this->expectExceptionMessage('ظرفیت');
|
||||
|
||||
DB::transaction(fn () => $this->service->consumeForOrder('LIMIT2', $this->user, $shipment));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_inactive_code()
|
||||
{
|
||||
DiscountCode::create([
|
||||
'code' => 'OFF',
|
||||
'type' => 'fixed',
|
||||
'value' => 10000,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$shipment = $this->makeShipment();
|
||||
|
||||
$this->expectException(DiscountCodeUnavailableException::class);
|
||||
|
||||
DB::transaction(fn () => $this->service->consumeForOrder('OFF', $this->user, $shipment));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_expired_code()
|
||||
{
|
||||
DiscountCode::create([
|
||||
'code' => 'OLD',
|
||||
'type' => 'fixed',
|
||||
'value' => 10000,
|
||||
'is_active' => true,
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$shipment = $this->makeShipment();
|
||||
|
||||
$this->expectException(DiscountCodeUnavailableException::class);
|
||||
|
||||
DB::transaction(fn () => $this->service->consumeForOrder('OLD', $this->user, $shipment));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_does_nothing_when_code_is_empty()
|
||||
{
|
||||
$shipment = $this->makeShipment();
|
||||
|
||||
// بدون کد — نه خطا، نه مصرف
|
||||
$this->assertFalse(
|
||||
DB::transaction(fn () => $this->service->consumeForOrder(null, $this->user, $shipment))
|
||||
);
|
||||
$this->assertFalse(
|
||||
DB::transaction(fn () => $this->service->consumeForOrder(' ', $this->user, $shipment))
|
||||
);
|
||||
$this->assertEquals(0, DiscountCodeUsage::count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function try_consume_returns_false_instead_of_throwing()
|
||||
{
|
||||
DiscountCode::create([
|
||||
'code' => 'DEAD',
|
||||
'type' => 'fixed',
|
||||
'value' => 10000,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$shipment = $this->makeShipment();
|
||||
|
||||
$this->assertFalse(
|
||||
$this->service->tryConsumeForOrder('DEAD', $this->user, $shipment)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,15 +2,19 @@
|
||||
|
||||
namespace Tests\Feature\Services;
|
||||
|
||||
use App\Enums\ShipmentType;
|
||||
use App\Models\Country;
|
||||
use App\Models\ShippingRate;
|
||||
use App\Models\CountryZone;
|
||||
use App\Models\RateEntry;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Services\PriceCalculatorService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* تست محاسبهگر قیمت — بر اساس معماری ۴-رُبعی (Export/Import × Doc/Parcel)
|
||||
* و ذخیرهسازی نرخها به ریال (IRR) — فاز ۳.۷ و ۳.۸.۱
|
||||
*/
|
||||
class PriceCalculatorServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
@ -18,67 +22,47 @@ class PriceCalculatorServiceTest extends TestCase
|
||||
#[Test]
|
||||
public function it_calculates_price_correctly_for_standard_package()
|
||||
{
|
||||
// 1. تنظیم SystemSetting ها برای تست (جلوگیری از مقادیر پیشفرض بزرگ)
|
||||
// ۱. تنظیم SystemSetting ها برای تست
|
||||
SystemSetting::create(['key' => 'profit_margin', 'value' => 1.0]); // بدون سود
|
||||
SystemSetting::create(['key' => 'aed_to_irr', 'value' => 1.0]); // بدون تبدیل ارز
|
||||
SystemSetting::create(['key' => 'vat_rate', 'value' => 0.0]); // بدون مالیات
|
||||
SystemSetting::create(['key' => 'packing_cost_default', 'value' => 0]); // بدون هزینه بستهبندی
|
||||
SystemSetting::create(['key' => 'vat_rate', 'value' => 0.0]); // بدون مالیات
|
||||
|
||||
// 2. ایجاد کشور
|
||||
$country = Country::factory()->create([
|
||||
// ۲. ایجاد کشور
|
||||
Country::factory()->create([
|
||||
'iso_code' => 'US',
|
||||
'name' => 'United States',
|
||||
'export_zone_parcel' => 1,
|
||||
'export_zone_doc' => 1,
|
||||
'import_zone_parcel' => 1,
|
||||
'import_zone_doc' => 1,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// 3. ایجاد نرخهای حملونقل
|
||||
ShippingRate::create([
|
||||
'direction' => 'export',
|
||||
'type' => 'DOC_NORMAL',
|
||||
'weight' => 1.0,
|
||||
'zone_1' => 20.00,
|
||||
'zone_2' => 0,
|
||||
'zone_3' => 0,
|
||||
'zone_4' => 0,
|
||||
'zone_5' => 0,
|
||||
'zone_6' => 0,
|
||||
'zone_7' => 0,
|
||||
'zone_8' => 0,
|
||||
'zone_9' => 0,
|
||||
'zone_10' => 0,
|
||||
// ۳. نگاشت زون — جدول country_zones (سیستم ۴-رُبعی)
|
||||
CountryZone::create([
|
||||
'country_name' => 'United States',
|
||||
'country_iso2' => 'US',
|
||||
'document_zone' => 1,
|
||||
'parcel_zone' => 1,
|
||||
]);
|
||||
|
||||
ShippingRate::create([
|
||||
// ۴. نرخ حمل — جدول rate_entries، مستقیماً به ریال (IRR)
|
||||
RateEntry::create([
|
||||
'direction' => 'export',
|
||||
'type' => 'DOC_NORMAL',
|
||||
'service_type' => 'document',
|
||||
'weight' => 3.0,
|
||||
'zone_1' => 40.00,
|
||||
'zone_2' => 0,
|
||||
'zone_3' => 0,
|
||||
'zone_4' => 0,
|
||||
'zone_5' => 0,
|
||||
'zone_6' => 0,
|
||||
'zone_7' => 0,
|
||||
'zone_8' => 0,
|
||||
'zone_9' => 0,
|
||||
'zone_10' => 0,
|
||||
'zone' => 1,
|
||||
'rate' => 40000.0, // ۴۰,۰۰۰ ریال
|
||||
'currency' => 'IRR',
|
||||
'effective_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// 4. اجرای سرویس
|
||||
// ۵. اجرای سرویس
|
||||
$service = app(PriceCalculatorService::class);
|
||||
|
||||
$data = [
|
||||
'direction' => 'Outbound', // ← توجه: در سرویس به 'export' تبدیل میشود
|
||||
'direction' => 'Outbound', // ← در سرویس به 'export' تبدیل میشود
|
||||
'type' => 'DOC_NORMAL',
|
||||
'country_iso' => 'US',
|
||||
'weight' => 2.5,
|
||||
'volumetric_weight' => 3.0,
|
||||
'extra_service' => 10.00,
|
||||
'packing_cost' => 0, // ← اضافه کردن هزینه بستهبندی صفر
|
||||
'extra_service' => 10000, // ۱۰,۰۰۰ ریال
|
||||
'packing_cost' => 0,
|
||||
'domestic_pickup' => 0,
|
||||
'domestic_delivery' => 0,
|
||||
'warehousing_cost' => 0,
|
||||
@ -86,23 +70,146 @@ class PriceCalculatorServiceTest extends TestCase
|
||||
|
||||
$result = $service->calculate($data);
|
||||
|
||||
// 5. بررسی ساختار خروجی
|
||||
// ۶. بررسی ساختار خروجی
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey('total_fee', $result); // ✅ تغییر به total_fee
|
||||
$this->assertArrayHasKey('total_fee', $result);
|
||||
$this->assertArrayHasKey('base_price', $result);
|
||||
|
||||
// 6. بررسی مقادیر
|
||||
|
||||
// ۷. بررسی مقادیر
|
||||
$this->assertEquals(1, $result['zone']);
|
||||
$this->assertEquals(3.0, $result['chargeable_weight']);
|
||||
$this->assertEquals(40.00, $result['base_price']);
|
||||
|
||||
// محاسبه دستی:
|
||||
// base_price = 40
|
||||
// net_rial = 40 × 1.0 (profit) × 1.0 (currency) = 40
|
||||
// extra = 10
|
||||
// subtotal = 50
|
||||
$this->assertEquals(40000.0, $result['base_price']);
|
||||
$this->assertFalse($result['discount_applied']);
|
||||
|
||||
// محاسبهٔ دستی (همه به ریال):
|
||||
// base_price = 40000
|
||||
// net_rial = 40000 × 1.0 (سود) + 10000 (خدمات) = 50000
|
||||
// vat = 0
|
||||
// total_fee = 50
|
||||
$this->assertEquals(50.00, $result['total_fee']);
|
||||
// تخفیف = 0
|
||||
// total_fee = گردش به نزدیکترین ۱۰۰۰ = 50000
|
||||
$this->assertEquals(50000, $result['total_fee']);
|
||||
$this->assertEquals(50000, $result['net_rial']);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_applies_discount_from_code_in_pricing()
|
||||
{
|
||||
SystemSetting::create(['key' => 'profit_margin', 'value' => 1.0]);
|
||||
SystemSetting::create(['key' => 'vat_rate', 'value' => 0.0]);
|
||||
|
||||
Country::factory()->create([
|
||||
'iso_code' => 'DE',
|
||||
'name' => 'Germany',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
CountryZone::create([
|
||||
'country_name' => 'Germany',
|
||||
'country_iso2' => 'DE',
|
||||
'document_zone' => 2,
|
||||
'parcel_zone' => 2,
|
||||
]);
|
||||
|
||||
RateEntry::create([
|
||||
'direction' => 'export',
|
||||
'service_type' => 'document',
|
||||
'weight' => 1.0,
|
||||
'zone' => 2,
|
||||
'rate' => 100000.0, // ۱۰۰,۰۰۰ ریال
|
||||
'currency' => 'IRR',
|
||||
'effective_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// کد تخفیف ۱۰٪ فعال با ظرفیت
|
||||
\App\Models\DiscountCode::create([
|
||||
'code' => 'PCT10',
|
||||
'type' => 'percentage',
|
||||
'value' => 10,
|
||||
'usage_limit' => 10,
|
||||
'used_count' => 0,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$service = app(PriceCalculatorService::class);
|
||||
|
||||
$result = $service->calculate([
|
||||
'direction' => 'export',
|
||||
'type' => 'DOC_NORMAL',
|
||||
'country_iso' => 'DE',
|
||||
'weight' => 1.0,
|
||||
'volumetric_weight' => 0,
|
||||
'extra_service' => 0,
|
||||
'packing_cost' => 0,
|
||||
'domestic_pickup' => 0,
|
||||
'domestic_delivery' => 0,
|
||||
'warehousing_cost' => 0,
|
||||
'discount_code' => 'PCT10',
|
||||
]);
|
||||
|
||||
// base = 100000، سود = 1.0، vat = 0 → subtotal = 100000
|
||||
// تخفیف = ۱۰٪ = 10000 → total = 90000 (گردش به ۱۰۰۰)
|
||||
$this->assertTrue($result['discount_applied']);
|
||||
$this->assertEquals(10000, $result['discount_amount']);
|
||||
$this->assertEquals(90000, $result['total_fee']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_ignores_discount_code_whose_usage_limit_is_reached()
|
||||
{
|
||||
SystemSetting::create(['key' => 'profit_margin', 'value' => 1.0]);
|
||||
SystemSetting::create(['key' => 'vat_rate', 'value' => 0.0]);
|
||||
|
||||
Country::factory()->create([
|
||||
'iso_code' => 'AE',
|
||||
'name' => 'United Arab Emirates',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
CountryZone::create([
|
||||
'country_name' => 'United Arab Emirates',
|
||||
'country_iso2' => 'AE',
|
||||
'document_zone' => 1,
|
||||
'parcel_zone' => 1,
|
||||
]);
|
||||
|
||||
RateEntry::create([
|
||||
'direction' => 'export',
|
||||
'service_type' => 'document',
|
||||
'weight' => 1.0,
|
||||
'zone' => 1,
|
||||
'rate' => 100000.0,
|
||||
'currency' => 'IRR',
|
||||
'effective_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// کد فعال ولی ظرفیتش پر شده — نباید در قیمت اعمال شود (سختسازی)
|
||||
\App\Models\DiscountCode::create([
|
||||
'code' => 'FULL',
|
||||
'type' => 'percentage',
|
||||
'value' => 50,
|
||||
'usage_limit' => 3,
|
||||
'used_count' => 3,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$service = app(PriceCalculatorService::class);
|
||||
|
||||
$result = $service->calculate([
|
||||
'direction' => 'export',
|
||||
'type' => 'DOC_NORMAL',
|
||||
'country_iso' => 'AE',
|
||||
'weight' => 1.0,
|
||||
'volumetric_weight' => 0,
|
||||
'extra_service' => 0,
|
||||
'packing_cost' => 0,
|
||||
'domestic_pickup' => 0,
|
||||
'domestic_delivery' => 0,
|
||||
'warehousing_cost' => 0,
|
||||
'discount_code' => 'FULL',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result['discount_applied']);
|
||||
$this->assertEquals(0, $result['discount_amount']);
|
||||
$this->assertEquals(100000, $result['total_fee']);
|
||||
}
|
||||
}
|
||||
|
||||
15
AGENT.md
15
AGENT.md
@ -36,6 +36,19 @@
|
||||
- [x] **جمعشدن کارت لاگین فیلمنت** — ۳۸۰px، فشردهتر
|
||||
- [x] **SweetAlert2** برای خطاهای فرم سفارش وردپرس (باندل محلی)
|
||||
|
||||
فاز ۳.۸.۱ (سختسازی Production) نیز تکمیل شد (۲۰۲۶-۱۰-۳۱):
|
||||
|
||||
- [x] **P0 — باگ کد تخفیف:** متد `consumeDiscountCode()` فراخوانی میشد ولی وجود نداشت (ثبت سفارش را میشکست). سرویس جدید `DiscountCodeService` با جدول `discount_code_usages` (Audit + Idempotency + قفل رقابتی) جایگزین شد؛ مصرف فقط در ثبت اولیه، ارسال مجدد دوباره مصرف نمیکند
|
||||
- [x] **گارد ظرفیت کد تخفیف در قیمتگذاری** — کدهای پرشده در `calculate()` هم اعمال نمیشوند
|
||||
- [x] **گارد Mock Gateway در Production** — اگر `APP_ENV=production` و درگاه تستی باشد، پردازش با `RuntimeException` + `Log::critical` مسدود میشود (قبلاً پیشفرض `sandbox=true` خطرناک بود)
|
||||
- [x] **حذف هکهای دیباگ وردپرس** — `opcache_reset()` + `header('Cache-Control')` از ۵ فایل پلاگین حذف شد
|
||||
- [x] **نسخهبندی سالم assetها** — cache-buster `'2.0.0-' . time()` (هر ریکوئست کش را میشکند) با `ifnex_asset_version()` مبتنی بر `filemtime` + ثابت `IFNEX_BRIDGE_VERSION` (2.1.0) جایگزین شد (۶ نقطه)
|
||||
- [x] **سختسازی آپلود تعهدنامه** — `mimetypes:application/pdf,image/jpeg,image/png` (بررسی محتوا، نه فقط پسوند)
|
||||
- [x] **خروج `wp-config.php` از گیت** — saltهای واقعی لورفته فرض میشوند؛ فایل در `.gitignore` و فقط `wp-config-sample.php` در مخزن است
|
||||
- [x] **`.env.example` بروز** — بلوک Production (لاگ روزانه، APP_DEBUG=false، کشها) + `IFNEX_BRIDGE_API_KEY` + راهنمای چرخش کلید
|
||||
- [x] **سند استقرار** — بخش ۴.۴ «چکلیست امنیتی سختسازی» در `DEPLOYMENT_HANDOFF.md`
|
||||
- [x] **تستها:** ۷ تست شکستهٔ قدیمی (auth کد تخفیف + معماری قدیمی محاسبهگر) تعمیر شد؛ ۷ تست جدید برای سرویس مصرف + ۲ تست برای ظرفیت/تخفیف — **۳۳ تست پاس، ۱۰۱ assertion**
|
||||
|
||||
چندزبانه (i18n) همچنان به زمان دیپلوی موکول است.
|
||||
|
||||
### معماری ارزی (Currency Architecture)
|
||||
@ -496,7 +509,7 @@ WALLET_AUTO_CREATE=true
|
||||
| ۲ | سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما) | ⏳ موکول به آینده | پایین |
|
||||
| ۳ | پلاگین SMS (بهصورت ماژول قابل فعالسازی) | ⏳ منتظر پنل SMS مشتری | متوسط |
|
||||
| ۴ | لاین واردات از چین (ماژول مجزا) | ⏳ مطالعاتاتی | پایین |
|
||||
| ۵ | دفترچه آدرس (Address Book) مشتری | 📋 مطرحشده | متوسط |
|
||||
| ۵ | دفترچه آدرس (Address Book) مشتری | 📋 مطرحشده — قدم بعدی | متوسط |
|
||||
| ۶ | **ارز مرجع قابل تنظیم** (نسخه Pro) | 📋 مطرحشده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) |
|
||||
| ۷ | سیستم بروزرسانی + لایسنس (Domain Lock) | 📋 مطرحشده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) |
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# IFNEX — چکلیست تحویل به مشتری
|
||||
|
||||
> **وضعیت:** آماده برای دیپلوی (فاز ۳.۷ تکمیل شد)
|
||||
> **فاز جاری:** فاز ۳.۷ — اصلاحات ارزی و فاکتور دستی (تکمیل)
|
||||
> **تاریخ آخرین بهروزرسانی:** ۲۰۲۶-۱۰-۳۰
|
||||
> **وضعیت:** آماده برای دیپلوی (فاز ۳.۸ و ۳.۸.۱ تکمیل شد)
|
||||
> **فاز جاری:** فاز ۳.۸.۱ — سختسازی Production (تکمیل)
|
||||
> **تاریخ آخرین بهروزرسانی:** ۲۰۲۶-۱۰-۳۱
|
||||
> **مخزن:** [git.vernahost.ir/gitmodir110/ifnex](https://www.git.vernahost.ir/gitmodir110/ifnex)
|
||||
|
||||
> ⚠️ این فایل فقط برای مدیریت تعهدات، تست و تحویل نسخهٔ فعلی مشتری است و جایگزین `AGENT.md` یا `IFNEX_Roadmap.md` نیست.
|
||||
@ -397,14 +397,15 @@ Create Import Order
|
||||
|
||||
| مورد | وضعیت |
|
||||
|------|--------|
|
||||
| حذف / چرخش اسکریتهای افشاشده | ⏳ |
|
||||
| بررسی نحوهٔ نگهداری Bridge API Key | ⏳ |
|
||||
| حذف اسکریپتهای دیباگ (opcache_reset / header / cache-buster) | ✅ ۲۰۲۶-۱۰-۳۱ — از ۵ فایل پلاگین حذف شد؛ cache-buster با filemtime جایگزین شد |
|
||||
| چرخش کلیدهای افشاشده | 🔑 در چکلیست استقرار (بخش ۴.۴ `DEPLOYMENT_HANDOFF.md`) — قبل از دیپلوی چرخش شود |
|
||||
| بررسی نحوهٔ نگهداری Bridge API Key | ✅ ۲۰۲۶-۱۰-۳۱ — `wp-config.php` از گیت حذف شد (فقط sample در مخزن)؛ راهنمای چرخش در `.env.example` |
|
||||
| بررسی مجوز روتهای Staff | ✅ `StaffApiMiddleware` روی همهٔ `/staff/*` |
|
||||
| بررسی عملیات مختص مدیر | ✅ Policy روی `CustomerCredit` و `Shipment` |
|
||||
| اعتبارسنجی آپلود فایل | ⏳ |
|
||||
| اعتبارسنجی آپلود فایل | ✅ ۲۰۲۶-۱۰-۳۱ — `mimetypes` (بررسی محتوا) + `mimes` + `max:5120` + دیسک secure + چک مالکیت |
|
||||
| بررسی مالکیت و مجوز اسناد | ✅ `ShipmentPolicy` و `CustomerCreditPolicy` |
|
||||
| امنیت Callback پرداخت | ✅ `OrderPaymentService` با محافظ Mock در Production |
|
||||
| غیرفعالسازی Mock Gateway در Production | ⏳ |
|
||||
| غیرفعالسازی Mock Gateway در Production | ✅ ۲۰۲۶-۱۰-۳۱ — گارد سخت: در `APP_ENV=production` درگاه تستی با RuntimeException مسدود میشود |
|
||||
| Rate Limiting | ✅ ۲۰۲۶-۱۰-۰۴ — ۶ لایه throttle: auth / sms / public / customer / wallet / staff |
|
||||
|
||||
## ۱۲.۲ یکپارچگی مالی
|
||||
@ -413,7 +414,7 @@ Create Import Order
|
||||
|------|--------|
|
||||
| حفاظت از همزمانی کیف پول | ✅ `lockForUpdate()` در `CustomerCreditService::settle()` |
|
||||
| یکتایی پرداخت (Idempotency) | ✅ race condition اصلاح شد |
|
||||
| منطق مصرف کد تخفیف | ⏳ |
|
||||
| منطق مصرف کد تخفیف | ✅ ۲۰۲۶-۱۰-۳۱ — `DiscountCodeService` + جدول `discount_code_usages`: قفل رقابتی، مصرف یکبار، رکورد Audit، گارد ظرفیت در قیمتگذاری، ۴۲۲ در شکست مصرف. باگ P0 تاریخی: متد `consumeDiscountCode()` وجود نداشت و ثبت سفارش را میشکست — برطرف شد |
|
||||
| دقت مبلغ و ارز | ✅ cast با `decimal:2` |
|
||||
| یکپارچگی بدهی چندارزی | ✅ بدهی به همان ارز ثبت میشود، نه معادل ریالی |
|
||||
|
||||
@ -423,11 +424,11 @@ Create Import Order
|
||||
|
||||
| مورد | وضعیت |
|
||||
|------|--------|
|
||||
| بازبینی فلوی قدیمی سفارش | ⏳ |
|
||||
| مدیریت خطاها | ⏳ |
|
||||
| لاگ Production | ⏳ |
|
||||
| پیکربندی Cache / OPcache | ⏳ |
|
||||
| نسخهبندی Assetها | ⏳ |
|
||||
| بازبینی فلوی قدیمی سفارش | ✅ ۲۰۲۶-۱۰-۳۱ — باگ P0 کد تخفیف کشف و برطرف شد؛ ۳۳ تست پاس (۱۰۱ assertion) شامل ثبت سفارش با تخفیف |
|
||||
| مدیریت خطاها | ✅ ۲۰۲۶-۱۰-۳۱ — شکست مصرف کد تخفیف → ۴۲۲ با پیام واضح؛ Mock در Production → خطای صریح؛ مسیرهای حساس Log::error/critical |
|
||||
| لاگ Production | ✅ ۲۰۲۶-۱۰-۳۱ — راهنمای `LOG_STACK=daily` + `LOG_LEVEL=warning` در `.env.example` و بخش ۴.۴ سند استقرار |
|
||||
| پیکربندی Cache / OPcache | ✅ ۲۰۲۶-۱۰-۳۱ — راهنمای `config:cache` / `route:cache` / `view:cache` در سند استقرار؛ هک OPcache حذف شد |
|
||||
| نسخهبندی Assetها | ✅ ۲۰۲۶-۱۰-۳۱ — `ifnex_asset_version()` مبتنی بر filemtime + ثابت `IFNEX_BRIDGE_VERSION` (۲.۱.۰) |
|
||||
|
||||
---
|
||||
|
||||
@ -501,9 +502,9 @@ Create Import Order
|
||||
|
||||
# ۱۵. خلاصهٔ وضعیت تحویل
|
||||
|
||||
**وضعیت فعلی:** آماده برای دیپلوی (فاز ۳.۷ و ۳.۸ تکمیل شد)
|
||||
**وضعیت فعلی:** آماده برای دیپلوی (فاز ۳.۸ و ۳.۸.۱ تکمیل شد)
|
||||
|
||||
**آخرین کامیت بازبینیشده:** ۲۰۲۶-۱۰-۳۱ — شامل فاز ۳.۸ (برندینگ: favicon، صفحهٔ برندشدهٔ `/`، جمعشدن کارت لاگین، SweetAlert2 خطاهای فرم سفارش).
|
||||
**آخرین کامیت بازبینیشده:** ۲۰۲۶-۱۰-۳۱ — فاز ۳.۸.۱ (سختسازی Production: فیکس باگ P0 کد تخفیف، گارد Mock در Production، حذف هکهای دیباگ، نسخهبندی asset، سختسازی آپلود، خروج wp-config از گیت، تعمیر تستها).
|
||||
|
||||
## ۱۵.۱ تکمیلشده در فاز ۳.۶ (۲۰۲۶-۱۰-۰۴)
|
||||
|
||||
@ -540,14 +541,25 @@ Create Import Order
|
||||
- [x] **جمعشدن کارت لاگین فیلمنت** — عرض ۴۴۰←۳۸۰px، padding و فاصلههای فشردهتر، موبایل-فرندلی
|
||||
- [x] **SweetAlert2 برای خطاهای فرم سفارش وردپرس** — باندل محلی (بدون CDN)، پاپآپ مرکزی برای خطاهای اعتبارسنجی/ثبت/ارتباط با fallback به alert
|
||||
|
||||
## ۱۵.۴ باقیمانده
|
||||
## ۱۵.۴ تکمیلشده در فاز ۳.۸.۱ — سختسازی Production (۲۰۲۶-۱۰-۳۱)
|
||||
|
||||
- [x] **باگ P0 کد تخفیف** — متد `consumeDiscountCode()` صدا زده میشد ولی وجود نداشت (ثبت سفارش شکسته بود)؛ سرویس `DiscountCodeService` + جدول `discount_code_usages` (Audit + Idempotency + قفل رقابتی) پیادهسازی شد
|
||||
- [x] **گارد Mock Gateway در Production** — پردازش با درگاه تستی در Production مسدود میشود
|
||||
- [x] **حذف هکهای دیباگ** از ۵ فایل پلاگین وردپرس (opcache_reset / header / cache-buster)
|
||||
- [x] **نسخهبندی سالم assetها** — filemtime + ثابت نسخه بهجای `time()`
|
||||
- [x] **سختسازی آپلود** — بررسی mimetype واقعی فایل
|
||||
- [x] **خروج `wp-config.php` از گیت** (saltها لورفته فرض میشوند — چرخش در چکلیست استقرار)
|
||||
- [x] **تعمیر ۷ تست شکسته + ۹ تست جدید** — ۳۳ تست پاس، ۱۰۱ assertion
|
||||
- [x] **چکلیست امنیتی استقرار** در `DEPLOYMENT_HANDOFF.md` (بخش ۴.۴)
|
||||
|
||||
## ۱۵.۵ باقیمانده
|
||||
|
||||
- [ ] i18n (موکول به زمان دیپلوی)
|
||||
- [ ] تست نهایی UAT کارفرما
|
||||
- [ ] دیپلوی روی سرور Production
|
||||
- [ ] دیپلوی روی سرور Production (با چرخش کلیدها طبق بخش ۴.۴ سند استقرار)
|
||||
- [ ] سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما — موکول به آینده)
|
||||
- [ ] پلاگین SMS (منتظر تحویل پنل SMS مشتری)
|
||||
- [ ] دفترچه آدرس (Address Book) مشتری — مطرحشده، آینده
|
||||
- [ ] دفترچه آدرس (Address Book) مشتری — قدم بعدی
|
||||
|
||||
### باقیمانده برای نسخه Pro (آینده)
|
||||
|
||||
|
||||
@ -267,6 +267,19 @@ UPDATE wp_postmeta
|
||||
|
||||
هر دو دامنه به گواهی SSL از Let's Encrypt نیاز دارند. پس از نصب گواهی، مطمئن شوید ریدایرکت HTTP به HTTPS فعال است.
|
||||
|
||||
### ۴.۴ 🔐 چکلیست امنیتی سختسازی (فاز ۳.۸.۱ — اجباری قبل از انتشار)
|
||||
|
||||
> **زمینه:** در فاز سختسازی Production این تغییرات امنیتی اعمال شد. پیش از رفتن روی سرور، موارد زیر را انجام دهید:
|
||||
|
||||
| # | اقدام | چرا؟ |
|
||||
|---|-------|------|
|
||||
| ۱ | **چرخش کلیدها** — `IFNEX_API_KEY` و `IFNEX_BRIDGE_API_KEY` با رشتهٔ تصادفی جدید (هر دو طرف لاراول و وردپرس یکسان) | مقادیر قبلی در تاریخچهٔ گیت ثبت شدهاند و باید لورفته فرض شوند: `php -r "echo bin2hex(random_bytes(32));"` |
|
||||
| ۲ | **Saltهای وردپرس** — همهٔ ۸ کلید/salt را از [api.wordpress.org/secret-key](https://api.wordpress.org/secret-key/1.1/salt/) بازتولید کنید | `wp-config.php` با saltهای لوکال قبلاً در مخزن کامیت شده بود — اکنون از گیت حذف شده و دیگر کامیت نمیشود |
|
||||
| ۳ | **درگاه پرداخت** — `ZARINPAL_MERCHANT_ID` واقعی + `ZARINPAL_SANDBOX=false` | اکنون کد در `APP_ENV=production` اگر درگاه تستی باشد، با خطای صریح از پردازش جلوگیری میکند (Log::critical در لاگ) |
|
||||
| ۴ | **لاگ Production** — `LOG_STACK=daily`، `LOG_LEVEL=warning`، `LOG_DAILY_DAYS=14`، `APP_DEBUG=false` | لاگ روزانه و بدون اطلاعات حساس |
|
||||
| ۵ | **کشها** — `config:cache` + `route:cache` + `view:cache` | سرعت + عدم افشای متغیرهای محیطی |
|
||||
| ۶ | **حذف فایلهای دیباگ** — مطمئن شوید نسخهٔ جدید پلاگین (`ifnex-bridge` ≥ 2.1.0) روی سرور است | هکهای `opcache_reset()` / `header()` / cache-buster با `time()` حذف شدند و با filemtime جایگزین شدند |
|
||||
|
||||
---
|
||||
|
||||
## ۵. چکلیست نهایی
|
||||
@ -278,7 +291,8 @@ UPDATE wp_postmeta
|
||||
- [ ] فایل `.env` تنظیم شد (دیتابیس، `APP_URL`، CORS، Zarinpal)
|
||||
- [ ] `IFNEX_API_KEY` و `IFNEX_BRIDGE_API_KEY` با رشتهٔ تصادفی جایگزین شدند (مقدار `change-this-secret-key` باقی نمانده)
|
||||
- [ ] `php artisan key:generate` اجرا شد
|
||||
- [ ] `php artisan migrate` اجرا شد
|
||||
- [ ] `php artisan migrate` اجرا شد (شامل مایگریشن جدید `discount_code_usages` — سختسازی فاز ۳.۸.۱)
|
||||
- [ ] تست کد تخفیف: ثبت سفارش با کد فعال → `used_count` افزایش یافت؛ سفارش دوم با کد تکمصرف → خطای 422
|
||||
- [ ] seedها اجرا شدند (`CountriesTableSeeder`، `SystemSettingSeeder`، `RoleAndPermissionSeeder`)
|
||||
- [ ] جدول `countries` پر است (۱۹۲ کشور)
|
||||
- [ ] حساب ادمین با نقش `super_admin` ساخته شد
|
||||
@ -308,7 +322,7 @@ UPDATE wp_postmeta
|
||||
- [ ] وردپرس به API لاراول وصل میشود (تست: لاگین مشتری)
|
||||
- [ ] خطای CORS داده نمیشود
|
||||
- [ ] صفحهٔ `/tracking/` با یک شمارهٔ AWB نتیجه برمیگرداند
|
||||
- [ ] پرداخت درگاه کار میکند (`ZARINPAL_MERCHANT_ID` تنظیم شده و `ZARINPAL_SANDBOX=false`)
|
||||
- [ ] پرداخت درگاه کار میکند (`ZARINPAL_MERCHANT_ID` تنظیم شده و `ZARINPAL_SANDBOX=false`) — توجه: از فاز ۳.۸.۱ کد در Production با درگاه تستی اصلاً اجرا نمیشود (گارد سختسازی)
|
||||
- [ ] ارسال پیامک Kavenegar تست شد
|
||||
- [ ] بارگذاری فایل (آپلود تعهدنامه) کار میکند
|
||||
- [ ] بکاپ دیتابیس تنظیم شده است
|
||||
|
||||
110
README-INSTALL.md
Normal file
110
README-INSTALL.md
Normal file
@ -0,0 +1,110 @@
|
||||
# 📦 بسته بروزرسانی IFNEX — فاز ۳.۸.۱ (سختسازی Production)
|
||||
|
||||
**تاریخ:** ۳۱ اکتبر ۲۰۲۶
|
||||
**تعداد فایلها:** ۲۱ فایل + این راهنما
|
||||
**⚠️ شامل یک فیکس بحرانی (P0):** باگ ثبت سفارش — حتماً اعمال شود
|
||||
|
||||
---
|
||||
|
||||
## 🚨 مهمترین فیکس: باگ P0 کد تخفیف
|
||||
|
||||
در بازبینی امنیتی کشف شد که متد `consumeDiscountCode()` در کنترلر سفارش **صدا زده میشد ولی هیچجا تعریف نشده بود** — یعنی:
|
||||
|
||||
> ❌ **ثبت سفارش مشتری در وضعیت فعلی کلاً خطای 500 میدهد!**
|
||||
|
||||
(این باگ از کامیت فلوی بازبینی/ارسال مجدد وارد کد شده بود و چون تستهای قدیمی شکسته بودند پنهان مانده بود)
|
||||
|
||||
**راهحل اعمالشده در این بسته:**
|
||||
- سرویس جدید `DiscountCodeService` + جدول `discount_code_usages`
|
||||
- مصرف یکبار هر کد + رکورد Audit کامل (چه کسی، چه سفارشی، چه مبلغی)
|
||||
- قفل رقابتی (`lockForUpdate`) — دو ثبت همزمان نمیتوانند ظرفیت را دور بزنند
|
||||
- خطای 422 با پیام واضح اگر کد بین محاسبه و ثبت منقضی شود
|
||||
- گارد ظرفیت در قیمتگذاری هم
|
||||
|
||||
---
|
||||
|
||||
## 🔧 بقیه تغییرات (سختسازی Production)
|
||||
|
||||
| # | فیکس | توضیح |
|
||||
|---|------|-------|
|
||||
| ۱ | **گارد Mock Gateway** | اگر روی سرور `APP_ENV=production` باشد و درگاه تستی تنظیم شده باشد، پردازش با خطای صریح مسدود میشود (قبلاً خطر پرداخت مجازی واقعی وجود داشت!) |
|
||||
| ۲ | **حذف هکهای دیباگ وردپرس** | `opcache_reset()` و `header('Cache-Control: no-store')` از ۵ فایل پلاگین حذف شد — در Production باعث header error و شکستن کش میشد |
|
||||
| ۳ | **نسخهبندی سالم assetها** | cache-buster `'2.0.0-' . time()` (هر بار کش را میشکست) با `filemtime` جایگزین شد — کش فقط وقتی فایل عوض شود باطل میشود |
|
||||
| ۴ | **سختسازی آپلود تعهدنامه** | `mimetypes` اضافه شد — محتوای واقعی فایل بررسی میشود نه فقط پسوند |
|
||||
| ۵ | **`.env.example` بروز** | بلوک Production (لاگ روزانه، APP_DEBUG=false، کشها) + `IFNEX_BRIDGE_API_KEY` + راهنمای چرخش کلید |
|
||||
| ۶ | **سند استقرار** | بخش جدید «۴.۴ چکلیست امنیتی سختسازی» در `DEPLOYMENT_HANDOFF.md` |
|
||||
| ۷ | **تعمیر و توسعه تستها** | ۷ تست شکستهٔ قدیمی تعمیر شد + ۹ تست جدید → **۳۳ تست پاس، ۱۰۱ assertion** ✅ |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ دو فایل از گیت حذف شدهاند (اقدام دستی لازم!)
|
||||
|
||||
این بسته شامل حذف ۲ فایل از **ایندکس گیت** است (نه از دیسک!):
|
||||
|
||||
| فایل | دلیل |
|
||||
|------|------|
|
||||
| `03_WordPress/wp-config.php` | حاوی saltهای واقعی بود — لورفته فرض میشود؛ فقط `wp-config-sample.php` در مخزن میماند |
|
||||
| `04_Laravel/.phpunit.result.cache` | فایل کش محلی — نباید در گیت باشد |
|
||||
|
||||
چون ZIP نمیتواند حذف انجام دهد، **بعد از اکسترکت** این دو دستور را در ریشه پروژه اجرا کنید:
|
||||
|
||||
```bash
|
||||
git rm --cached 03_WordPress/wp-config.php
|
||||
git rm --cached 04_Laravel/.phpunit.result.cache
|
||||
```
|
||||
|
||||
> ✅ `.gitignore` بروز شده و بعد از این، این فایلها دیگر کامیت نمیشوند.
|
||||
> فایل `wp-config.php` روی دیسک شما دستنخورده باقی میماند.
|
||||
|
||||
---
|
||||
|
||||
## 📥 روش نصب
|
||||
|
||||
1. محتوای پوشه `ifnex-updates/` را **دقیقاً روی ریشه پروژه IFNEX** اکسترکت کنید (Replace)
|
||||
2. دو دستور `git rm --cached` بالا را اجرا کنید
|
||||
3. مراحل بعد از اکسترکت (پایین) را انجام دهید
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ مراحل بعد از اکسترکت
|
||||
|
||||
### Laravel:
|
||||
```bash
|
||||
cd 04_Laravel
|
||||
|
||||
# مایگریشن جدید (جدول discount_code_usages)
|
||||
php artisan migrate
|
||||
|
||||
# اجرای تستها — باید ۳۳ پاس ببینید
|
||||
php artisan test
|
||||
```
|
||||
|
||||
### WordPress:
|
||||
- پلاگین `ifnex-bridge` را غیرفعال و دوباره فعال کنید
|
||||
- اگر کش افزونه دارید، پاکسازی کنید
|
||||
|
||||
---
|
||||
|
||||
## ✅ چکلیست تست سریع
|
||||
|
||||
| # | تست | نتیجه مورد انتظار |
|
||||
|---|-----|-------------------|
|
||||
| 1 | `php artisan test` | **33 passed, 101 assertions** |
|
||||
| 2 | `php artisan migrate` | مایگریشن `discount_code_usages` بدون خطا |
|
||||
| 3 | ثبت سفارش **بدون** کد تخفیف | موفق (این قبلاً 500 میداد!) |
|
||||
| 4 | ثبت سفارش **با** کد تخفیف فعال | موفق + `used_count` کد +1 میشود |
|
||||
| 5 | ثبت سفارش دوم با همان کد (اگر usage_limit=1) | خطای 422 با پیام واضح |
|
||||
| 6 | فرم سفارش وردپرس | بدون خطای کنسول؛ assetها با `?ver=2.1.0.xxx` لود میشوند |
|
||||
|
||||
---
|
||||
|
||||
## 📤 بعد از تست موفق
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "apply: IFNEX phase 3.8.1 package (production hardening + P0 discount fix)"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
> ⚠️ **قبل از دیپلوی روی سرور:** چکلیست بخش ۴.۴ فایل `DEPLOYMENT_HANDOFF.md` را حتماً انجام دهید
|
||||
> (چرخش کلیدها، بازتولید saltهای وردپرس، تنظیم درگاه واقعی، لاگ روزانه، کشها).
|
||||
Loading…
Reference in New Issue
Block a user