change copperConvertPage

This commit is contained in:
2026-11-01 12:42:14 +03:30
parent 9528056036
commit 3f59af5a6d
10 changed files with 495 additions and 3 deletions
@@ -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؛ نه «سایر»
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\CopperConvertRequestService;
class CopperConvertRequestController extends Controller
{
public function index(CopperConvertRequestService $service)
{
return response()->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);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Services\CopperConvertRequestService;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
class CopperConvertRequestController extends Controller
{
public function store(Request $request, CopperConvertRequestService $service)
{
$customer = auth('customer')->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);
}
}
@@ -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'])) {
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();
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CopperConvertRequest extends Model
{
protected $fillable = [
'customer_id',
'status',
'source',
'source_product_id',
'asset_unit',
'consumed_amount',
'leftover_amount',
'source_unit_price',
'wire15_product_id',
'wire15_rings',
'wire15_unit_price',
'wire25_product_id',
'wire25_rings',
'wire25_unit_price',
'sell_order_id',
'buy15_order_id',
'buy25_order_id',
'admin_id',
];
protected $casts = [
'consumed_amount' => 'float',
'leftover_amount' => 'float',
];
public function customer()
{
return $this->belongsTo(Customer::class);
}
}
+2
View File
@@ -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',
@@ -0,0 +1,305 @@
<?php
namespace App\Services;
use App\Http\Controllers\Single\SingleOrderController;
use App\Models\CopperConvertRequest;
use App\Models\Customer;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
class CopperConvertRequestService
{
public function submit(Customer $customer, string $source, int $rings15, int $rings25): CopperConvertRequest
{
if ($rings15 < 1 && $rings25 < 1) {
throw new UnprocessableEntityHttpException('حداقل یک حلقه انتخاب کنید.');
}
$pending = CopperConvertRequest::query()
->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;
}
}
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('copper_convert_requests')) {
return;
}
Schema::create('copper_convert_requests', function (Blueprint $table) {
$table->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');
}
};
+4
View File
@@ -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']);
+2
View File
@@ -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']);