feat(logic): integrate discount code application and wallet services

Implement the core logic for applying discount codes during price
calculation and introduce the WalletService. This includes updating
the PriceCalculatorService to validate and apply discounts,
incrementing usage counts, and adding a Filament resource for
managing discount codes via the admin panel.

Additionally, refactor TrackingDataImport to use OnEachRow for better
memory management during large Excel imports.
This commit is contained in:
Kazem Alghasi 2026-08-05 19:16:09 +03:30
parent 6aa18f76a8
commit f5917c34c2
8 changed files with 366 additions and 86 deletions

View File

@ -0,0 +1,64 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\DiscountCodeResource\Pages;
use App\Filament\Resources\DiscountCodeResource\RelationManagers;
use App\Models\DiscountCode;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class DiscountCodeResource extends Resource
{
protected static ?string $model = DiscountCode::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
public static function form(Form $form): Form
{
return $form
->schema([
//
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
//
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListDiscountCodes::route('/'),
'create' => Pages\CreateDiscountCode::route('/create'),
'edit' => Pages\EditDiscountCode::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\DiscountCodeResource\Pages;
use App\Filament\Resources\DiscountCodeResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateDiscountCode extends CreateRecord
{
protected static string $resource = DiscountCodeResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DiscountCodeResource\Pages;
use App\Filament\Resources\DiscountCodeResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditDiscountCode extends EditRecord
{
protected static string $resource = DiscountCodeResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DiscountCodeResource\Pages;
use App\Filament\Resources\DiscountCodeResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListDiscountCodes extends ListRecords
{
protected static string $resource = DiscountCodeResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -6,92 +6,83 @@ use App\Models\Shipment;
use App\Models\ShipmentTrackingEvent; use App\Models\ShipmentTrackingEvent;
use App\Models\Country; use App\Models\Country;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Maatwebsite\Excel\Concerns\ToCollection; use Maatwebsite\Excel\Concerns\OnEachRow;
use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Illuminate\Support\Collection; use Maatwebsite\Excel\Row;
class TrackingDataImport implements ToCollection, WithHeadingRow class TrackingDataImport implements OnEachRow, WithHeadingRow
{ {
protected $importedEvents = 0; protected $importedEvents = 0;
protected $skippedEvents = 0; protected $skippedEvents = 0;
protected $errors = []; protected $errors = [];
protected $createdShipments = 0; protected $createdShipments = 0;
public function collection(Collection $rows) public function onRow(Row $row)
{ {
Log::info('Tracking import started', ['total_rows' => $rows->count()]); $rowIndex = $row->getIndex();
$rowArray = $row->toArray();
if ($rows->count() === 0) { try {
Log::warning('No rows found'); // استخراج و نرمال‌سازی داده‌ها از ردیف اکسل
return; $awbNo = $this->normalizeAwb($rowArray['awb'] ?? null);
} $eventDate = $this->normalizeDate($rowArray['date'] ?? null);
$eventTime = $this->normalizeTime($rowArray['time'] ?? null);
$eventDescription = $this->normalizeDescription($rowArray['state'] ?? null);
$location = $this->normalizeLocation($rowArray['country'] ?? null);
$deliveryStatus = $this->normalizeDeliveryStatus($rowArray['last_state'] ?? null);
$firstRow = $rows->first(); // بررسی اعتبار داده‌های ضروری
Log::info('First row keys', ['keys' => $firstRow->keys()->toArray()]); if (!$awbNo || !$eventDate || !$eventDescription) {
Log::info('First row awb', ['awb' => $firstRow['awb'] ?? 'N/A']);
foreach ($rows as $index => $row) {
try {
$awbNo = $this->normalizeAwb($row['awb'] ?? null);
$eventDate = $this->normalizeDate($row['date'] ?? null);
$eventTime = $this->normalizeTime($row['time'] ?? null);
$eventDescription = $this->normalizeDescription($row['state'] ?? null);
$location = $this->normalizeLocation($row['country'] ?? null);
$deliveryStatus = $this->normalizeDeliveryStatus($row['last_state'] ?? null);
if (!$awbNo || !$eventDate || !$eventDescription) {
$this->skippedEvents++;
continue;
}
$shipment = Shipment::where('awb_no', $awbNo)->first();
if (!$shipment) {
$shipment = $this->createShipmentFromTrackingRow($row, $awbNo, $location);
$this->createdShipments++;
}
ShipmentTrackingEvent::create([
'shipment_id' => $shipment->id,
'event_date' => $eventDate,
'event_time' => $eventTime,
'location' => $location,
'event_description' => $eventDescription,
'delivery_status' => $deliveryStatus,
'source' => 'manual',
]);
$this->importedEvents++;
} catch (\Throwable $e) {
$this->skippedEvents++; $this->skippedEvents++;
$this->errors[] = "Row {$index}: " . $e->getMessage(); return;
Log::error('Tracking import error', ['row' => $index, 'error' => $e->getMessage()]);
} }
}
Log::info('Tracking import completed', [ // جستجوی مرسوله بر اساس AWB
'imported' => $this->importedEvents, $shipment = Shipment::where('awb_no', $awbNo)->first();
'skipped' => $this->skippedEvents,
'created' => $this->createdShipments, // اگر مرسوله وجود نداشت، آن را ایجاد کن
'errors' => count($this->errors), if (!$shipment) {
]); $shipment = $this->createShipmentFromTrackingRow($rowArray, $awbNo, $location);
$this->createdShipments++;
}
// ثبت رویداد ترکینگ
ShipmentTrackingEvent::create([
'shipment_id' => $shipment->id,
'event_date' => $eventDate,
'event_time' => $eventTime,
'location' => $location,
'event_description' => $eventDescription,
'delivery_status' => $deliveryStatus,
'source' => 'manual',
]);
$this->importedEvents++;
} catch (\Throwable $e) {
$this->skippedEvents++;
$this->errors[] = "Row {$rowIndex}: " . $e->getMessage();
Log::error('Tracking import error', ['row' => $rowIndex, 'error' => $e->getMessage()]);
}
} }
protected function createShipmentFromTrackingRow(array $row, string $awbNo, ?string $location): Shipment protected function createShipmentFromTrackingRow(array $row, string $awbNo, ?string $location): Shipment
{ {
// استخراج نام کشور از لوکیشن
$countryName = $location ? explode(' - ', $location)[0] : null; $countryName = $location ? explode(' - ', $location)[0] : null;
$country = Country::where('name', 'like', "%{$countryName}%")->first(); $country = Country::where('name', 'like', "%{$countryName}%")->first();
// تعیین وضعیت مرسوله بر اساس آخرین وضعیت
$lastState = strtolower($row['last_state'] ?? ''); $lastState = strtolower($row['last_state'] ?? '');
$status = 'processed'; $status = 'processed';
if (str_contains($lastState, 'delivered')) $status = 'delivered'; if (str_contains($lastState, 'delivered')) $status = 'delivered';
elseif (str_contains($lastState, 'in transit')) $status = 'in_transit'; elseif (str_contains($lastState, 'in transit')) $status = 'in_transit';
elseif (str_contains($lastState, 'picked')) $status = 'picked_up'; elseif (str_contains($lastState, 'picked')) $status = 'picked_up';
elseif (str_contains($lastState, 'out for delivery')) $status = 'out_for_delivery'; elseif (str_contains($lastState, 'out for delivery')) $status = 'out_for_delivery';
elseif (str_contains($lastState, 'failed')) $status = 'failed'; elseif (str_contains($lastState, 'failed')) $status = 'failed';
$shipment = Shipment::create([ // ایجاد مرسوله جدید با مقادیر پیش‌فرض
return Shipment::create([
'awb_no' => $awbNo, 'awb_no' => $awbNo,
'direction' => 'export', 'direction' => 'export',
'type' => 'PARCEL', 'type' => 'PARCEL',
@ -115,29 +106,9 @@ class TrackingDataImport implements ToCollection, WithHeadingRow
'sender_name' => 'Unknown', 'sender_name' => 'Unknown',
'receiver_name' => 'Unknown', 'receiver_name' => 'Unknown',
]); ]);
return $shipment;
} }
public function getImportedEvents(): int // --- متدهای کمکی برای نرمال‌سازی داده‌ها ---
{
return $this->importedEvents;
}
public function getSkippedEvents(): int
{
return $this->skippedEvents;
}
public function getErrors(): array
{
return $this->errors;
}
public function getCreatedShipments(): int
{
return $this->createdShipments;
}
protected function normalizeAwb($value): ?string protected function normalizeAwb($value): ?string
{ {
@ -221,4 +192,26 @@ class TrackingDataImport implements ToCollection, WithHeadingRow
return null; return null;
} }
// --- متدهای دریافت آمار ---
public function getImportedEvents(): int
{
return $this->importedEvents;
}
public function getSkippedEvents(): int
{
return $this->skippedEvents;
}
public function getErrors(): array
{
return $this->errors;
}
public function getCreatedShipments(): int
{
return $this->createdShipments;
}
} }

View File

@ -7,8 +7,14 @@ use Illuminate\Database\Eloquent\Model;
class DiscountCode extends Model class DiscountCode extends Model
{ {
protected $fillable = [ protected $fillable = [
'code', 'type', 'value', 'min_order_amount', 'code',
'usage_limit', 'used_count', 'expires_at', 'is_active' 'type',
'value',
'min_order_amount',
'usage_limit',
'used_count',
'expires_at',
'is_active'
]; ];
protected $casts = [ protected $casts = [

View File

@ -4,7 +4,7 @@ namespace App\Services;
use App\Enums\ShipmentType; use App\Enums\ShipmentType;
use App\Models\Country; use App\Models\Country;
use App\Models\Shipment; use App\Models\DiscountCode;
use App\Models\SystemSetting; use App\Models\SystemSetting;
use InvalidArgumentException; use InvalidArgumentException;
@ -40,11 +40,31 @@ class PriceCalculatorService
$domesticPickup = (float) ($data['domestic_pickup'] ?? 0); $domesticPickup = (float) ($data['domestic_pickup'] ?? 0);
$domesticDelivery = (float) ($data['domestic_delivery'] ?? 0); $domesticDelivery = (float) ($data['domestic_delivery'] ?? 0);
$warehousingCost = (float) ($data['warehousing_cost'] ?? 0); $warehousingCost = (float) ($data['warehousing_cost'] ?? 0);
$discount = (float) ($data['discount'] ?? 0);
// متغیر $discount را از آرایه داده‌ها دریافت می‌کنیم (اگر وجود داشته باشد)
// اما در اینجا ما از کد تخفیف استفاده می‌کنیم، پس این خط را حذف یا اصلاح می‌کنیم
// $discount = (float) ($data['discount'] ?? 0);
$subtotal = $netRial + $extraService + $packingCost + $domesticPickup + $domesticDelivery + $warehousingCost - $discount; // محاسبه قیمت اولیه قبل از تخفیف کد
$totalFee = $subtotal * (1 + $vatRate); $subtotal = $netRial + $extraService + $packingCost + $domesticPickup + $domesticDelivery + $warehousingCost;
$vatAmount = $totalFee - $subtotal;
// اعمال کد تخفیف (در صورت وجود)
$discountCode = $data['discount_code'] ?? null;
$discountResult = $this->applyDiscount($subtotal, $discountCode);
if ($discountResult['success']) {
$finalPrice = $discountResult['final_price'];
// افزایش شمارنده استفاده از کد تخفیف
$discount = DiscountCode::where('code', $discountResult['discount_code'])->first();
if ($discount) {
$discount->increment('used_count');
}
} else {
$finalPrice = $subtotal;
}
$totalFee = $finalPrice * (1 + $vatRate);
$vatAmount = $totalFee - $finalPrice;
return [ return [
'base_price' => $rate, 'base_price' => $rate,
@ -55,7 +75,9 @@ class PriceCalculatorService
'domestic_pickup' => $domesticPickup, 'domestic_pickup' => $domesticPickup,
'domestic_delivery' => $domesticDelivery, 'domestic_delivery' => $domesticDelivery,
'warehousing_cost' => $warehousingCost, 'warehousing_cost' => $warehousingCost,
'discount' => $discount, 'discount_applied' => $discountResult['success'],
'discount_amount' => $discountResult['discount_amount'],
'discount_message' => $discountResult['message'],
'vat_amount' => $vatAmount, 'vat_amount' => $vatAmount,
'total_fee' => $totalFee, 'total_fee' => $totalFee,
'zone' => $zone, 'zone' => $zone,
@ -63,6 +85,98 @@ class PriceCalculatorService
]; ];
} }
/**
* اعمال کد تخفیف روی قیمت نهایی
*
* @param float $totalPrice قیمت کل قبل از تخفیف
* @param string|null $code کد تخفیف وارد شده توسط کاربر
* @return array ['success' => bool, 'message' => string, 'discount_amount' => float, 'final_price' => float]
*/
public function applyDiscount(float $totalPrice, ?string $code): array
{
if (empty($code)) {
return [
'success' => false,
'message' => 'کد تخفیف وارد نشده است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
$discount = DiscountCode::where('code', $code)->first();
// 1. بررسی وجود کد
if (!$discount) {
return [
'success' => false,
'message' => 'کد تخفیف نامعتبر است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// 2. بررسی فعال بودن کد
if (!$discount->is_active) {
return [
'success' => false,
'message' => 'این کد تخفیف غیرفعال شده است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// 3. بررسی تاریخ انقضا
if ($discount->expires_at && $discount->expires_at->isPast()) {
return [
'success' => false,
'message' => 'این کد تخفیف منقضی شده است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// 4. بررسی حداقل مبلغ سفارش
if ($discount->min_order_amount > 0 && $totalPrice < $discount->min_order_amount) {
return [
'success' => false,
'message' => "حداقل مبلغ سفارش برای استفاده از این کد " . number_format($discount->min_order_amount) . " ریال است.",
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// 5. بررسی محدودیت استفاده
if ($discount->usage_limit && $discount->used_count >= $discount->usage_limit) {
return [
'success' => false,
'message' => 'ظرفیت استفاده از این کد تخفیف به پایان رسیده است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// محاسبه مبلغ تخفیف
$discountAmount = 0;
if ($discount->type === 'percentage') {
$discountAmount = ($totalPrice * $discount->value) / 100;
} elseif ($discount->type === 'fixed') {
$discountAmount = $discount->value;
}
// اطمینان از اینکه تخفیف بیشتر از مبلغ کل نشود
$discountAmount = min($discountAmount, $totalPrice);
$finalPrice = $totalPrice - $discountAmount;
return [
'success' => true,
'message' => 'کد تخفیف با موفقیت اعمال شد.',
'discount_amount' => $discountAmount,
'final_price' => $finalPrice,
'discount_code' => $discount->code, // برای ذخیره در سفارش
];
}
private function lookupRate(string $direction, ShipmentType $type, float $weight, int $zone): float private function lookupRate(string $direction, ShipmentType $type, float $weight, int $zone): float
{ {
$zoneColumn = 'zone_' . $zone; $zoneColumn = 'zone_' . $zone;

View File

@ -0,0 +1,53 @@
<?php
namespace App\Services;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use Illuminate\Support\Facades\DB;
class WalletService
{
/**
* واریز وجه به کیف پول
*/
public function deposit(Wallet $wallet, float $amount, string $description = null, $transactionable = null): WalletTransaction
{
return DB::transaction(function () use ($wallet, $amount, $description, $transactionable) {
$wallet->increment('balance', $amount);
return $this->createTransaction($wallet, $amount, 'deposit', $description, $transactionable);
});
}
/**
* برداشت وجه از کیف پول
*/
public function withdraw(Wallet $wallet, float $amount, string $description = null, $transactionable = null): WalletTransaction
{
if ($wallet->balance < $amount) {
throw new \Exception('موجودی کیف پول کافی نیست.');
}
return DB::transaction(function () use ($wallet, $amount, $description, $transactionable) {
$wallet->decrement('balance', $amount);
// مبلغ برداشت به صورت منفی ثبت می‌شود
return $this->createTransaction($wallet, -$amount, 'withdraw', $description, $transactionable);
});
}
/**
* ثبت تراکنش در دیتابیس
*/
private function createTransaction(Wallet $wallet, float $amount, string $type, ?string $description, $transactionable): WalletTransaction
{
return WalletTransaction::create([
'wallet_id' => $wallet->id,
'amount' => $amount,
'type' => $type,
'description' => $description,
'transactionable_type' => $transactionable ? get_class($transactionable) : null,
'transactionable_id' => $transactionable?->id,
]);
}
}