Files
boursecu_server/app/Services/CopperConvertRequestService.php
T
safavimohadeseh78andCursor 9769f2ffad feat(convert): let customers list and change pending requests
History is limited to the signed-in customer. Update and delete only apply while the request is still pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 13:04:08 +03:30

428 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Http\Controllers\Controller;
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(
$this->quoteAttributes($customer, $source, $rings15, $rings25, $quote)
);
}
public function update(Customer $customer, int $id, string $source, int $rings15, int $rings25): CopperConvertRequest
{
if ($rings15 < 1 && $rings25 < 1) {
throw new UnprocessableEntityHttpException('حداقل یک حلقه انتخاب کنید.');
}
return DB::transaction(function () use ($customer, $id, $source, $rings15, $rings25) {
$row = $this->ownedPending($customer, $id);
$quote = $this->quote($customer, $source, $rings15, $rings25);
$row->fill($this->quoteAttributes($customer, $source, $rings15, $rings25, $quote));
$row->save();
return $row;
});
}
public function remove(Customer $customer, int $id): void
{
DB::transaction(function () use ($customer, $id) {
$this->ownedPending($customer, $id)->delete();
});
}
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 listForCustomer(Customer $customer): array
{
return CopperConvertRequest::query()
->where('customer_id', $customer->id)
->orderByDesc('id')
->limit(50)
->get()
->map(function (CopperConvertRequest $row) {
return [
'id' => $row->id,
'status' => $row->status,
'source' => $row->source,
'asset_unit' => $row->asset_unit,
'consumed_amount' => $row->consumed_amount,
'leftover_amount' => $row->leftover_amount,
'wire15_rings' => $row->wire15_rings,
'wire25_rings' => $row->wire25_rings,
'created_at' => optional($row->created_at)->toDateTimeString(),
];
})
->all();
}
public function approve(int $id, int $adminId): CopperConvertRequest
{
$row = 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;
});
$this->notifyCustomer($row, true);
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();
$this->notifyCustomer($row, false);
return $row;
}
private function notifyCustomer(CopperConvertRequest $row, bool $approved): void
{
try {
$customer = Customer::query()->find($row->customer_id);
$mobile = trim((string) ($customer?->mobile ?? ''));
if ($mobile === '') {
return;
}
$company = trim((string) Controller::Config('name_fa'));
$source = $this->sourceLabel((string) $row->source);
$lines = [$company !== '' ? $company : 'درخواست تبدیل'];
if ($approved) {
$lines[] = 'درخواست تبدیل #'.$row->id.' تأیید شد.';
$lines[] = 'دارایی '.$source.' به مقدار '.$row->consumed_amount.' '.$row->asset_unit.' فروخته شد.';
if ((int) $row->wire15_rings > 0) {
$lines[] = 'حلقه ۱.۵: '.$row->wire15_rings;
}
if ((int) $row->wire25_rings > 0) {
$lines[] = 'حلقه ۲.۵: '.$row->wire25_rings;
}
} else {
$lines[] = 'درخواست تبدیل #'.$row->id.' رد شد.';
$lines[] = 'دارایی '.$source.' شما بدون تغییر ماند.';
}
Controller::Send($mobile, implode("\n", $lines));
} catch (\Throwable $e) {
report($e);
}
}
private function ownedPending(Customer $customer, int $id): CopperConvertRequest
{
$row = CopperConvertRequest::query()
->where('customer_id', $customer->id)
->lockForUpdate()
->find($id);
if (!$row) {
throw new UnprocessableEntityHttpException('درخواست تبدیل پیدا نشد.');
}
if ($row->status !== 'pending') {
throw new UnprocessableEntityHttpException('فقط درخواست در انتظار تایید قابل تغییر است.');
}
return $row;
}
private function quoteAttributes(Customer $customer, string $source, int $rings15, int $rings25, array $quote): array
{
return [
'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'],
];
}
private function sourceLabel(string $source): string
{
if ($source === 'katod') {
return 'کاتد';
}
if ($source === 'maftoul') {
return 'مفتول';
}
if ($source === 'shamsh') {
return 'شمش';
}
return $source;
}
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;
}
}