diff --git a/.cursor/skills/abshode-domain-lookup/learned-notes.md b/.cursor/skills/abshode-domain-lookup/learned-notes.md index b9cd63a..26e55b8 100644 --- a/.cursor/skills/abshode-domain-lookup/learned-notes.md +++ b/.cursor/skills/abshode-domain-lookup/learned-notes.md @@ -3,6 +3,7 @@ فرمت هر خط: - YYYY-MM-DD | موضوع | مسیر فایل(ها) | نکتهٔ ۱ خطی +- 2026-11-01 | درخواست تبدیل مس | `CopperConvertRequestService` + `copper_convert_requests` + `POST customer/copper-convert-request` + `admin/copper-convert-request/{id}/approve` | تأیید = فروش دارایی و خرید حلقه با quote_unit_price؛ مهاجرت `2026_11_01_000001` - 2026-10-28 | شایگان کالا/مشتری | `products.shayegan_item_number` + `ShayeganSyncService::registerCustomer` Account/Put + `ensureCustomerAccountNumber` | بانک 10001 و معین 12101 در settings؛ ItemNumber per-product - 2026-10-17 | لاگ ترافیک شایگان | `ShayeganClient::request` | `Log::info` با کلید `shayegan.http_request` / `shayegan.http_response`; رمز `***`; خاموش: setting `shayegan_log_traffic=0` - 2026-10-17 | گروه شایگان در تنظیمات پیشرفته | `settingGroups.resolveSettingMenu` + migration `2026_10_17_000001` | اگر `menu=misc` ولی کلید `shayegan_*` → گروه accounting_shayegan؛ نه «سایر» diff --git a/app/Http/Controllers/Admin/CopperConvertRequestController.php b/app/Http/Controllers/Admin/CopperConvertRequestController.php new file mode 100644 index 0000000..c1e4c25 --- /dev/null +++ b/app/Http/Controllers/Admin/CopperConvertRequestController.php @@ -0,0 +1,28 @@ +json($service->listForAdmin()); + } + + public function approve(int $id, CopperConvertRequestService $service) + { + $row = $service->approve($id, (int) auth('user')->id()); + + return response()->json($row); + } + + public function reject(int $id, CopperConvertRequestService $service) + { + $row = $service->reject($id, (int) auth('user')->id()); + + return response()->json($row); + } +} diff --git a/app/Http/Controllers/Customer/CopperConvertRequestController.php b/app/Http/Controllers/Customer/CopperConvertRequestController.php new file mode 100644 index 0000000..1c8aa24 --- /dev/null +++ b/app/Http/Controllers/Customer/CopperConvertRequestController.php @@ -0,0 +1,38 @@ +user(); + if (!$customer instanceof Customer) { + throw new UnprocessableEntityHttpException('مشتری یافت نشد.'); + } + self::assertCustomerIdentityVerified($customer); + if (!$customer->trade_active) { + throw new UnprocessableEntityHttpException('امکان سفارش برای شما غیرفعال است.'); + } + + $source = (string) $request->input('source'); + if (!in_array($source, ['katod', 'maftoul', 'shamsh'], true)) { + throw new UnprocessableEntityHttpException('نوع دارایی نامعتبر است.'); + } + + $row = $service->submit( + $customer, + $source, + max(0, (int) $request->input('rings15')), + max(0, (int) $request->input('rings25')) + ); + + return response()->json($row); + } +} diff --git a/app/Http/Controllers/Single/SingleOrderController.php b/app/Http/Controllers/Single/SingleOrderController.php index 7d09ae6..e4bbd19 100644 --- a/app/Http/Controllers/Single/SingleOrderController.php +++ b/app/Http/Controllers/Single/SingleOrderController.php @@ -122,7 +122,9 @@ class SingleOrderController extends Controller // Check if the user wants to transact with a product or non-product (gold) if (isset($data['product_id']) and $data['product'] = Product::find($data['product_id'])) { - $config->assertProductTradingOpen($data['product']); + if (empty($data['quote_unit_price'])) { + $config->assertProductTradingOpen($data['product']); + } // Check if the user wants to buy if ($data['direction'] == 1) { @@ -353,6 +355,7 @@ class SingleOrderController extends Controller $data['total_price'] = $data['amount'] * $data['price']; $data['payment_amount'] = $data['total_price'] + $data['service_charge_price']; $data['fee_asset'] = 'rial'; + $this->applyConvertQuote($data); $this->enforceProductOrderLimits($data, (float) $data['total_price']); @@ -366,7 +369,7 @@ class SingleOrderController extends Controller if ($wallet->cash < $data['payment_amount']) { throw new UnprocessableEntityHttpException('موجودی شما کمتر از میزان سفارش است'); } - if (!$data['product']['open_buy']) { + if (empty($data['quote_unit_price']) && !$data['product']['open_buy']) { throw new UnprocessableEntityHttpException('خرید این محصول در این لحظه مقدور نمیباشد'); } $this->syncOrderProfitFields($data); @@ -647,6 +650,7 @@ class SingleOrderController extends Controller $data['total_price'] = $data['amount'] * $data['price'] - $data['service_charge_price']; $data['payment_amount'] = $data['total_price']; $data['fee_asset'] = 'rial'; + $this->applyConvertQuote($data); if ($data['total_price'] < 0) { throw new UnprocessableEntityHttpException( 'مبلغ دریافتی فروش پس از کارمزد منفی شد. درصد کارمزد سفارش را بررسی کنید (باید اعشاری باشد؛ مثلاً ۰٫۰۱ برای ۱٪).' @@ -659,7 +663,7 @@ class SingleOrderController extends Controller if ($wallet_interface_product->wallet->gold < $data['number']) { throw new UnprocessableEntityHttpException('موجودی شما کمتر از میزان سفارش است'); } - if (!$data['product']['open_sell']) { + if (empty($data['quote_unit_price']) && !$data['product']['open_sell']) { throw new UnprocessableEntityHttpException('فروش این محصول در این لحظه مقدور نمیباشد'); } $this->syncOrderProfitFields($data); @@ -1403,6 +1407,32 @@ class SingleOrderController extends Controller * اگر قیمت واحد سفارش حدود قیمت مثقال باشد، به قیمت هر گرم برگردان. * جلوگیری از ۱۰ میلیون تومان → ۱۰۵ سوت وقتی مثقال در فیلد «هر گرم» نشسته. */ + /** قیمت قفل‌شدهٔ تبدیل: تعداد × قیمت واحد، بدون کارمزد و بدون ضرب در وزن واحد. */ + private function applyConvertQuote(array &$data): void + { + $quote = (float) ($data['quote_unit_price'] ?? 0); + if ($quote <= 0) { + return; + } + + $number = (float) ($data['number'] ?? 0); + $data['amount'] = $number; + $data['price'] = $quote; + $data['price_original'] = $quote; + $data['difference'] = 0; + $data['fee'] = 0; + $data['total_fee'] = 0; + $data['service_charge'] = 0; + $data['service_charge_price'] = 0; + $data['service_charge_percentage'] = 0; + $data['total_amount'] = $number; + $data['total_price'] = $number * $quote; + $data['payment_amount'] = $data['total_price']; + $data['fee_asset'] = 'rial'; + $data['fee_free'] = true; + $data['first_buy'] = false; + } + private function ensureOrderUnitPriceIsPerGram(array &$data): void { $live = $this->liveGramQuoteRial(); diff --git a/app/Models/CopperConvertRequest.php b/app/Models/CopperConvertRequest.php new file mode 100644 index 0000000..cd2a6a3 --- /dev/null +++ b/app/Models/CopperConvertRequest.php @@ -0,0 +1,39 @@ + 'float', + 'leftover_amount' => 'float', + ]; + + public function customer() + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Services/AdminPageAccess.php b/app/Services/AdminPageAccess.php index 54ad880..16be897 100644 --- a/app/Services/AdminPageAccess.php +++ b/app/Services/AdminPageAccess.php @@ -21,6 +21,7 @@ class AdminPageAccess 'productsMain_archive', 'productsMain_categories', 'ordersMain', + 'copperConvertMain', 'transactionsMain', 'accountsMain', 'walletMain', @@ -87,6 +88,7 @@ class AdminPageAccess 'dashboard' => 'dashboardMain', 'main' => 'dashboardMain', 'orders' => 'ordersMain', + 'copperConvert' => 'copperConvertMain', 'transactions' => 'transactionsMain', 'support' => 'supportMain', 'factor' => 'withdrawMain', diff --git a/app/Services/CopperConvertRequestService.php b/app/Services/CopperConvertRequestService.php new file mode 100644 index 0000000..4e26296 --- /dev/null +++ b/app/Services/CopperConvertRequestService.php @@ -0,0 +1,305 @@ +where('customer_id', $customer->id) + ->where('status', 'pending') + ->exists(); + if ($pending) { + throw new UnprocessableEntityHttpException('یک درخواست تبدیل در انتظار تأیید دارید.'); + } + + $quote = $this->quote($customer, $source, $rings15, $rings25); + + return CopperConvertRequest::query()->create([ + 'customer_id' => $customer->id, + 'status' => 'pending', + 'source' => $source, + 'source_product_id' => $quote['source']->id, + 'asset_unit' => $quote['unit'], + 'consumed_amount' => $quote['consumed'], + 'leftover_amount' => $quote['leftover'], + 'source_unit_price' => $quote['source_unit_price'], + 'wire15_product_id' => $quote['wire15']?->id, + 'wire15_rings' => $rings15, + 'wire15_unit_price' => $quote['price15'], + 'wire25_product_id' => $quote['wire25']?->id, + 'wire25_rings' => $rings25, + 'wire25_unit_price' => $quote['price25'], + ]); + } + + public function listForAdmin(): array + { + return CopperConvertRequest::query() + ->with('customer:id,fname,lname,name,mobile') + ->orderByDesc('id') + ->limit(200) + ->get() + ->map(function (CopperConvertRequest $row) { + $customer = $row->customer; + $name = trim((string) ($customer->name ?? '')); + if ($name === '') { + $name = trim(((string) ($customer->fname ?? '')).' '.((string) ($customer->lname ?? ''))); + } + + return [ + 'id' => $row->id, + 'status' => $row->status, + 'source' => $row->source, + 'customer_name' => $name !== '' ? $name : 'مشتری', + 'customer_mobile' => $customer->mobile ?? '', + 'asset_unit' => $row->asset_unit, + 'consumed_amount' => $row->consumed_amount, + 'leftover_amount' => $row->leftover_amount, + 'source_unit_price' => $row->source_unit_price, + 'wire15_rings' => $row->wire15_rings, + 'wire15_unit_price' => $row->wire15_unit_price, + 'wire25_rings' => $row->wire25_rings, + 'wire25_unit_price' => $row->wire25_unit_price, + 'created_at' => optional($row->created_at)->toDateTimeString(), + ]; + }) + ->all(); + } + + public function approve(int $id, int $adminId): CopperConvertRequest + { + return DB::transaction(function () use ($id, $adminId) { + $row = CopperConvertRequest::query()->lockForUpdate()->findOrFail($id); + if ($row->status !== 'pending') { + throw new UnprocessableEntityHttpException('این درخواست قبلاً تعیین‌تکلیف شده است.'); + } + + $customer = Customer::query()->findOrFail($row->customer_id); + $available = $this->availableQuantity($customer, (int) $row->source_product_id); + if ($available + 0.0001 < (float) $row->consumed_amount) { + throw new UnprocessableEntityHttpException('موجودی مشتری برای این تبدیل کافی نیست.'); + } + + $note = 'درخواست تبدیل #'.$row->id; + $sellId = $this->placeLeg($customer, (int) $row->source_product_id, 2, (float) $row->consumed_amount, (float) $row->source_unit_price, $note); + $buy15 = (int) $row->wire15_rings > 0 + ? $this->placeLeg($customer, (int) $row->wire15_product_id, 1, (float) $row->wire15_rings, (float) $row->wire15_unit_price, $note) + : null; + $buy25 = (int) $row->wire25_rings > 0 + ? $this->placeLeg($customer, (int) $row->wire25_product_id, 1, (float) $row->wire25_rings, (float) $row->wire25_unit_price, $note) + : null; + + $row->status = 'approved'; + $row->admin_id = $adminId; + $row->sell_order_id = $sellId; + $row->buy15_order_id = $buy15; + $row->buy25_order_id = $buy25; + $row->save(); + + return $row; + }); + } + + public function reject(int $id, int $adminId): CopperConvertRequest + { + $row = CopperConvertRequest::query()->findOrFail($id); + if ($row->status !== 'pending') { + throw new UnprocessableEntityHttpException('این درخواست قبلاً تعیین‌تکلیف شده است.'); + } + $row->status = 'rejected'; + $row->admin_id = $adminId; + $row->save(); + + return $row; + } + + private function placeLeg(Customer $customer, int $productId, int $direction, float $number, float $unitPrice, string $note): int + { + if ($productId < 1 || $number <= 0 || $unitPrice <= 0) { + throw new UnprocessableEntityHttpException('اطلاعات سفارش تبدیل ناقص است.'); + } + + auth('customer')->setUser($customer); + try { + $response = app(SingleOrderController::class)->store(Request::create('/admin/single/order', 'POST', [ + 'customer_id' => $customer->id, + 'product_id' => $productId, + 'direction' => $direction, + 'number' => $number, + 'method' => 'cash', + 'price' => $unitPrice, + 'difference' => 0, + 'quote_unit_price' => $unitPrice, + 'description' => $note, + ])); + } finally { + auth('customer')->forgetUser(); + } + $body = $response->getData(true); + $orderId = (int) ($body[1]['order']['id'] ?? 0); + if ($orderId < 1) { + throw new UnprocessableEntityHttpException('ثبت سفارش تبدیل انجام نشد.'); + } + + return $orderId; + } + + private function quote(Customer $customer, string $source, int $rings15, int $rings25): array + { + $products = Product::query()->where('active', 1)->get(); + $sourceProduct = $this->findSource($products, $source); + if (!$sourceProduct) { + throw new UnprocessableEntityHttpException('محصول این دارایی پیدا نشد.'); + } + + $wire15 = $rings15 > 0 ? $this->findWire($products, '1.5') : null; + $wire25 = $rings25 > 0 ? $this->findWire($products, '2.5') : null; + if ($rings15 > 0 && !$wire15) { + throw new UnprocessableEntityHttpException('محصول سیم ۱.۵ پیدا نشد.'); + } + if ($rings25 > 0 && !$wire25) { + throw new UnprocessableEntityHttpException('محصول سیم ۲.۵ پیدا نشد.'); + } + + $prices = app(ProductDisplayPriceService::class); + $sourcePerKg = (float) $prices->resolveOrderTradePrice($sourceProduct, 1); + $price15 = $wire15 ? (int) round((float) $prices->resolveOrderTradePrice($wire15, 1)) : 0; + $price25 = $wire25 ? (int) round((float) $prices->resolveOrderTradePrice($wire25, 1)) : 0; + if ($sourcePerKg <= 0 || ($rings15 > 0 && $price15 <= 0) || ($rings25 > 0 && $price25 <= 0)) { + throw new UnprocessableEntityHttpException('قیمت تبدیل در دسترس نیست.'); + } + + $unit = trim((string) ($sourceProduct->unit ?: 'کیلوگرم')); + if ($unit === '') { + $unit = 'کیلوگرم'; + } + $available = $this->availableQuantity($customer, (int) $sourceProduct->id); + $availableValue = $this->toKg($available, $unit, $sourceProduct) * $sourcePerKg; + $spent = ($rings15 * $price15) + ($rings25 * $price25); + if ($spent > $availableValue + 1) { + throw new UnprocessableEntityHttpException('تعداد حلقه از ارزش دارایی بیشتر است.'); + } + + $consumedKg = $spent / $sourcePerKg; + $consumed = round($this->fromKg($consumedKg, $unit, $sourceProduct), 4); + if ($consumed <= 0) { + throw new UnprocessableEntityHttpException('مقدار قابل تبدیل صفر است.'); + } + if ($consumed > $available + 0.0001) { + throw new UnprocessableEntityHttpException('موجودی این دارایی کافی نیست.'); + } + + $leftover = round(max(0, $available - $consumed), 4); + $sourceUnitPrice = (int) ceil($spent / $consumed); + + return [ + 'source' => $sourceProduct, + 'wire15' => $wire15, + 'wire25' => $wire25, + 'unit' => $unit, + 'consumed' => $consumed, + 'leftover' => $leftover, + 'source_unit_price' => max(1, $sourceUnitPrice), + 'price15' => (int) round($price15), + 'price25' => (int) round($price25), + ]; + } + + private function availableQuantity(Customer $customer, int $productId): float + { + return (float) app(AssetSummaryService::class)->productDisplayQuantity( + (int) $customer->id, + (int) ($customer->type ?? 2), + $productId + ); + } + + private function findSource(Collection $products, string $source): ?Product + { + return $products->first(function (Product $product) use ($source) { + $group = trim((string) $product->group_type); + $title = (string) $product->title; + if ($source === 'katod') { + return $group === 'کاتد مس' || str_contains($title, 'کاتد'); + } + if ($source === 'maftoul') { + return $group === 'مفتول مس' || str_contains($title, 'مفتول'); + } + if ($source === 'shamsh') { + return str_contains($group, 'شمش') || str_contains($title, 'شمش'); + } + + return false; + }); + } + + private function findWire(Collection $products, string $section): ?Product + { + return $products->first(function (Product $product) use ($section) { + $title = (string) $product->title; + $pattern = $section === '1.5' ? '/1\.5|۱\.۵/u' : '/2\.5|۲\.۵/u'; + + return preg_match($pattern, $title) === 1; + }); + } + + private function toKg(float $quantity, string $unit, Product $product): float + { + if ($quantity <= 0) { + return 0; + } + if (str_contains($unit, 'کیلو')) { + return $quantity; + } + if (str_contains($unit, 'گرم')) { + return $quantity / 1000; + } + $perPiece = (float) ($product->amount ?? 0); + if ($perPiece <= 0) { + return $quantity; + } + $pieceUnit = trim((string) $product->unit); + $grams = str_contains($pieceUnit, 'کیلو') ? $perPiece * 1000 : $perPiece; + + return ($quantity * $grams) / 1000; + } + + private function fromKg(float $kg, string $unit, Product $product): float + { + if ($kg <= 0) { + return 0; + } + if (str_contains($unit, 'کیلو')) { + return $kg; + } + if (str_contains($unit, 'گرم')) { + return $kg * 1000; + } + $perPiece = (float) ($product->amount ?? 0); + if ($perPiece <= 0) { + return $kg; + } + $pieceUnit = trim((string) $product->unit); + $grams = str_contains($pieceUnit, 'کیلو') ? $perPiece * 1000 : $perPiece; + if ($grams <= 0) { + return $kg; + } + + return ($kg * 1000) / $grams; + } +} diff --git a/database/migrations/2026_11_01_000001_create_copper_convert_requests_table.php b/database/migrations/2026_11_01_000001_create_copper_convert_requests_table.php new file mode 100644 index 0000000..a69fe78 --- /dev/null +++ b/database/migrations/2026_11_01_000001_create_copper_convert_requests_table.php @@ -0,0 +1,43 @@ +id(); + $table->unsignedBigInteger('customer_id')->index(); + $table->string('status', 20)->default('pending')->index(); + $table->string('source', 20); + $table->unsignedBigInteger('source_product_id'); + $table->string('asset_unit', 32); + $table->decimal('consumed_amount', 16, 4); + $table->decimal('leftover_amount', 16, 4)->default(0); + $table->unsignedBigInteger('source_unit_price'); + $table->unsignedBigInteger('wire15_product_id')->nullable(); + $table->unsignedInteger('wire15_rings')->default(0); + $table->unsignedBigInteger('wire15_unit_price')->default(0); + $table->unsignedBigInteger('wire25_product_id')->nullable(); + $table->unsignedInteger('wire25_rings')->default(0); + $table->unsignedBigInteger('wire25_unit_price')->default(0); + $table->unsignedBigInteger('sell_order_id')->nullable(); + $table->unsignedBigInteger('buy15_order_id')->nullable(); + $table->unsignedBigInteger('buy25_order_id')->nullable(); + $table->unsignedBigInteger('admin_id')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('copper_convert_requests'); + } +}; diff --git a/routes/admin.php b/routes/admin.php index 879531e..2e9f368 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Admin\ConstController; use App\Http\Controllers\Admin\OrderController; +use App\Http\Controllers\Admin\CopperConvertRequestController; use App\Http\Controllers\Admin\PriceController; use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\Admin\CustomerController; @@ -209,6 +210,9 @@ Route::middleware(['auth:user', 'admin.page'])->group(function (){ Route::post('order/single/table', [OrderController::class, 'singleOrderTable']); Route::post('order/colleague/table', [OrderController::class, 'colleagueTable']); Route::post('single/order', [SingleOrderController::class, 'store']); + Route::get('copper-convert-request', [CopperConvertRequestController::class, 'index']); + Route::post('copper-convert-request/{id}/approve', [CopperConvertRequestController::class, 'approve']); + Route::post('copper-convert-request/{id}/reject', [CopperConvertRequestController::class, 'reject']); //-------------------price Route::post('price', [PriceController::class, 'store']); diff --git a/routes/customer.php b/routes/customer.php index 273a3f0..e586c62 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Customer\CustomerController; use App\Http\Controllers\Customer\DigipayInstallmentController; use App\Http\Controllers\Customer\GoldCreditController; use App\Http\Controllers\Customer\OrderController; +use App\Http\Controllers\Customer\CopperConvertRequestController; use App\Http\Controllers\Customer\AddressController; use App\Http\Controllers\Customer\WalletController; use App\Http\Controllers\Customer\PriceTriggerController; @@ -131,6 +132,7 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use ( Route::get('order/pending', [SingleOrderController::class, 'pendingColleague']); Route::get('order/{id}/status', [SingleOrderController::class, 'colleagueStatus']); Route::post('order', [OrderController::class, 'store']); + Route::post('copper-convert-request', [CopperConvertRequestController::class, 'store']); //-----------------single order Route::post('single/order/table', [SingleOrderController::class, 'table']);