added setting for user

This commit is contained in:
2026-08-16 22:00:23 +03:30
parent c3c3a60088
commit bd8284e4f8
11 changed files with 769 additions and 12 deletions
@@ -0,0 +1,264 @@
<?php
namespace App\Http\Controllers\Admin;
use App\DB\Te\TableEngine;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\CustomerSpecialPricePackage;
use App\Models\Product;
use App\Services\ConfigService;
use App\Services\CustomerSpecialPricingService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
class CustomerSpecialPricePackageController extends Controller
{
public function table(Request $request)
{
$this->assertFeatureEnabled();
$query = CustomerSpecialPricePackage::query()
->withCount(['customers', 'products'])
->orderByDesc('id');
TableEngine::create($query, $request->all())
->initSearch(['title'])
->select(['*'])
->export($query);
return $query;
}
public function show(int $id)
{
$this->assertFeatureEnabled();
$package = CustomerSpecialPricePackage::query()
->with([
'customers:id,name,mobile',
'products:id,title,group_type',
])
->findOrFail($id);
$categoryNames = [];
if (Schema::hasTable('customer_special_price_package_category')) {
$categoryNames = DB::table('customer_special_price_package_category')
->where('package_id', $package->id)
->pluck('category_name')
->all();
}
return response()->json([
'package' => $package,
'customer_ids' => $package->customers->pluck('id')->values(),
'product_ids' => $package->products->pluck('id')->values(),
'category_names' => array_values($categoryNames),
'customers' => $package->customers,
'products' => $package->products,
]);
}
public function store(Request $request)
{
$this->assertFeatureEnabled();
$data = $this->validatedPayload($request);
$warnings = app(CustomerSpecialPricingService::class)->overlapWarnings(
$data['customer_ids'],
$data['product_ids']
);
$package = DB::transaction(function () use ($data) {
$package = CustomerSpecialPricePackage::create([
'title' => $data['title'],
'profit_buy' => $data['profit_buy'],
'profit_sell' => $data['profit_sell'],
'show_product' => $data['show_product'],
'is_active' => $data['is_active'],
]);
$this->syncRelations($package, $data);
return $package->fresh();
});
return response()->json([
'message' => 'پکیج قیمت ویژه ثبت شد.',
'package' => $package,
'warnings' => $warnings,
]);
}
public function update(Request $request, int $id)
{
$this->assertFeatureEnabled();
$package = CustomerSpecialPricePackage::findOrFail($id);
$data = $this->validatedPayload($request);
$warnings = app(CustomerSpecialPricingService::class)->overlapWarnings(
$data['customer_ids'],
$data['product_ids'],
(int) $package->id
);
DB::transaction(function () use ($package, $data) {
$package->update([
'title' => $data['title'],
'profit_buy' => $data['profit_buy'],
'profit_sell' => $data['profit_sell'],
'show_product' => $data['show_product'],
'is_active' => $data['is_active'],
]);
$this->syncRelations($package, $data);
});
return response()->json([
'message' => 'پکیج قیمت ویژه به‌روز شد.',
'package' => $package->fresh(),
'warnings' => $warnings,
]);
}
public function destroy(int $id)
{
$this->assertFeatureEnabled();
$package = CustomerSpecialPricePackage::findOrFail($id);
DB::transaction(function () use ($package) {
$package->customers()->detach();
$package->products()->detach();
if (Schema::hasTable('customer_special_price_package_category')) {
DB::table('customer_special_price_package_category')->where('package_id', $package->id)->delete();
}
$package->delete();
});
return response()->json(['message' => 'پکیج حذف شد.']);
}
public function toggleActive(int $id)
{
$this->assertFeatureEnabled();
$package = CustomerSpecialPricePackage::findOrFail($id);
$package->is_active = ! $package->is_active;
$package->save();
return response()->json([
'message' => $package->is_active ? 'پکیج فعال شد.' : 'پکیج غیرفعال شد.',
'is_active' => $package->is_active,
]);
}
public function searchCustomers(Request $request)
{
$this->assertFeatureEnabled();
$q = trim((string) $request->get('q', ''));
$rows = Customer::query()
->where('type', 2)
->where(function ($query) {
$query->where('is_archive', false)->orWhereNull('is_archive');
})
->when($q !== '', function ($query) use ($q) {
$query->where(function ($inner) use ($q) {
$inner->where('name', 'like', '%' . $q . '%')
->orWhere('mobile', 'like', '%' . $q . '%')
->orWhere('id', $q);
});
})
->orderByDesc('id')
->limit(40)
->get(['id', 'name', 'mobile']);
return response()->json(['customers' => $rows]);
}
public function productsByCategories(Request $request)
{
$this->assertFeatureEnabled();
$names = array_values(array_filter(array_map('strval', (array) $request->input('category_names', []))));
if ($names === []) {
return response()->json(['products' => []]);
}
$products = Product::query()
->whereIn('group_type', $names)
->orderBy('title')
->get(['id', 'title', 'group_type', 'active']);
return response()->json(['products' => $products]);
}
private function validatedPayload(Request $request): array
{
$title = trim((string) $request->input('title', ''));
if ($title === '') {
throw new UnprocessableEntityHttpException('عنوان پکیج الزامی است.');
}
$customerIds = array_values(array_unique(array_filter(array_map('intval', (array) $request->input('customer_ids', [])))));
$productIds = array_values(array_unique(array_filter(array_map('intval', (array) $request->input('product_ids', [])))));
$categoryNames = array_values(array_unique(array_filter(array_map(
fn ($n) => trim((string) $n),
(array) $request->input('category_names', [])
))));
if ($customerIds === []) {
throw new UnprocessableEntityHttpException('حداقل یک کاربر انتخاب کنید.');
}
if ($categoryNames === []) {
throw new UnprocessableEntityHttpException('حداقل یک گروه دسته‌بندی انتخاب کنید.');
}
if ($productIds === []) {
throw new UnprocessableEntityHttpException('حداقل یک محصول انتخاب کنید.');
}
$profitBuy = (int) preg_replace('/[^\d]/', '', (string) $request->input('profit_buy', 0));
$profitSell = (int) preg_replace('/[^\d]/', '', (string) $request->input('profit_sell', 0));
return [
'title' => $title,
'customer_ids' => $customerIds,
'product_ids' => $productIds,
'category_names' => $categoryNames,
'profit_buy' => max(0, $profitBuy),
'profit_sell' => max(0, $profitSell),
'show_product' => filter_var($request->input('show_product', true), FILTER_VALIDATE_BOOLEAN),
'is_active' => filter_var($request->input('is_active', true), FILTER_VALIDATE_BOOLEAN),
];
}
private function syncRelations(CustomerSpecialPricePackage $package, array $data): void
{
$package->customers()->sync($data['customer_ids']);
$package->products()->sync($data['product_ids']);
if (Schema::hasTable('customer_special_price_package_category')) {
DB::table('customer_special_price_package_category')->where('package_id', $package->id)->delete();
$rows = [];
foreach ($data['category_names'] as $name) {
$rows[] = [
'package_id' => $package->id,
'category_name' => $name,
];
}
if ($rows !== []) {
DB::table('customer_special_price_package_category')->insert($rows);
}
}
}
private function assertFeatureEnabled(): void
{
if (! app(CustomerSpecialPricingService::class)->tablesReady()) {
throw new UnprocessableEntityHttpException('جدول پکیج قیمت ویژه وجود ندارد. مایگریشن را اجرا کنید.');
}
if (! app(ConfigService::class)->getBool(CustomerSpecialPricingService::SETTING_KEY, false)) {
throw new UnprocessableEntityHttpException('قابلیت قیمت ویژه مشتری غیرفعال است.');
}
}
}
@@ -572,6 +572,11 @@ class ProductController extends SuperController
$products->each(function (Product $product) use ($displayPriceService) {
$product->setAttribute('show', $displayPriceService->resolveCustomerShow($product));
});
$customerId = (int) (auth('customer')->id() ?? 0);
if ($customerId > 0) {
app(\App\Services\CustomerSpecialPricingService::class)->applyToProducts($products, $customerId);
}
}
/**
@@ -1451,10 +1451,42 @@ class SingleOrderController extends Controller
];
}
$customerId = (int) (auth('customer')->id() ?? 0);
if ($customerId <= 0) {
$customerId = (int) (request()->input('customer_id') ?? 0);
}
$specialSvc = app(\App\Services\CustomerSpecialPricingService::class);
$specialRule = $specialSvc->ruleForCustomerProduct($customerId, (int) ($product->id ?? 0));
if ($specialRule && ! ($specialRule['show_product'] ?? true)) {
throw new UnprocessableEntityHttpException('این محصول برای شما در دسترس نیست.');
}
$specialAmt = $specialSvc->profitForDirection($specialRule, $direction);
if ($specialAmt > 0) {
$amount += $specialAmt;
$keys[] = 'customer_special_pricing';
$lines[] = [
'kind' => 'special_pricing',
'key' => 'customer_special_pricing',
'label' => 'قیمت ویژه مشتری · ' . ($specialRule['title'] ?? '') . ' · ' . $side,
'is_percent' => false,
'raw' => $specialAmt,
'per_unit' => $specialAmt,
'detail' => number_format($specialAmt) . ' ریال / گرم',
'package_id' => $specialRule['package_id'] ?? null,
];
}
$hasApp = collect($lines)->contains(fn ($l) => ($l['kind'] ?? '') === 'app_spread');
$hasProduct = collect($lines)->contains(fn ($l) => ($l['kind'] ?? '') === 'product_spread');
$hasSpecial = collect($lines)->contains(fn ($l) => ($l['kind'] ?? '') === 'special_pricing');
if ($hasApp && $hasProduct) {
if ($hasSpecial && ($hasApp || $hasProduct)) {
$source = 'app+product+special';
$label = 'اختلاف پایه/محصول + قیمت ویژه مشتری = ' . number_format($amount) . ' ریال / گرم';
} elseif ($hasSpecial) {
$source = 'special';
$label = ($lines[array_key_last($lines)]['label'] ?? 'قیمت ویژه مشتری') . ' = ' . number_format($amount) . ' ریال';
} elseif ($hasApp && $hasProduct) {
$source = 'app+product';
$label = 'قیمت صفحه اصلی اپلیکیشن + تب اختلاف محصول = ' . number_format($amount) . ' ریال / گرم';
} elseif ($hasApp) {
@@ -54,6 +54,10 @@ class PriceStreamController extends Controller
$cache = app(LivePriceCacheService::class);
$last = $cache->get() ?? $cache->buildFromDatabase();
if (is_array($last) && $last !== []) {
if ($customerId > 0) {
$last = app(\App\Services\CustomerSpecialPricingService::class)
->applyToLastPayload($last, $customerId);
}
$send('last', app(ZaarboxPriceService::class)->attachGoldOunceToPayload($last));
}
@@ -62,6 +66,13 @@ class PriceStreamController extends Controller
$send('list', $list);
}
if ($customerId > 0) {
$send(
'special-pricing',
app(\App\Services\CustomerSpecialPricingService::class)->clientPayloadForCustomer($customerId)
);
}
$customerLastId = 0;
if ($customerId > 0) {
$assets = app(AssetSummaryService::class)->buildForCustomer(
+21 -9
View File
@@ -197,14 +197,14 @@ class ReportController extends Controller
if ($cached !== null) {
$cached['gold_ounce'] = $this->resolveGoldOuncePayload();
return $cached;
return $this->personalizeLastPriceForCustomer($cached);
}
// فقط وقتی کران اخیراً tick زده — وگرنه API زنده (جلوگیری از قفل قیمت)
if ($cache->hasRecentTick()) {
$dbBuilt = $cache->buildFromDatabase();
if (($dbBuilt['price'] ?? 0) > 0) {
return $this->appendUpPrices($dbBuilt, false);
return $this->personalizeLastPriceForCustomer($this->appendUpPrices($dbBuilt, false));
}
}
}
@@ -226,7 +226,7 @@ class ReportController extends Controller
);
}
return $this->appendUpPrices($storePayload, $forceRefresh);
return $this->personalizeLastPriceForCustomer($this->appendUpPrices($storePayload, $forceRefresh));
}
$closed = false;
@@ -246,7 +246,7 @@ class ReportController extends Controller
};
$zeroResponse = function () use ($forceRefresh) {
return $this->appendUpPrices([
return $this->personalizeLastPriceForCustomer($this->appendUpPrices([
'sell' => 0,
'buy' => 0,
'price_last' => 0,
@@ -256,7 +256,7 @@ class ReportController extends Controller
'zaarbox' => 0,
'closed' => true,
'time' => now(),
], $forceRefresh);
], $forceRefresh));
};
$priceProvider = $config->priceProvider();
@@ -440,7 +440,7 @@ class ReportController extends Controller
$priceSource = 'database_last';
} else {
// مظنه زنده نداریم و show_last_price خاموش است → نه «بسته»، بلکه «مظنه در دسترس نیست»
return $this->appendUpPrices([
return $this->personalizeLastPriceForCustomer($this->appendUpPrices([
'sell' => 0,
'buy' => 0,
'price_last' => 0,
@@ -451,7 +451,7 @@ class ReportController extends Controller
'closed' => false,
'status' => 'no_quote',
'time' => now(),
], $forceRefresh);
], $forceRefresh));
}
}
@@ -485,7 +485,7 @@ class ReportController extends Controller
);
}
return $this->appendUpPrices([
return $this->personalizeLastPriceForCustomer($this->appendUpPrices([
'sell' => $sell,
'buy' => $buy,
'price_last' => $priceLast,
@@ -496,7 +496,19 @@ class ReportController extends Controller
'zaarbox' => $zaarbox_price ?? 0,
'closed' => false,
'time' => now(),
], $forceRefresh);
], $forceRefresh));
}
/** اختلاف پکیج ویژه — فقط روی پاسخ مشتری؛ کش مشترک را آلوده نمی‌کند */
protected function personalizeLastPriceForCustomer(array $payload): array
{
$customerId = (int) (auth('customer')->id() ?? 0);
if ($customerId <= 0) {
return $payload;
}
return app(\App\Services\CustomerSpecialPricingService::class)
->applyToLastPayload($payload, $customerId);
}
protected function recordHalfHourPriceSnapshot(
@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class CustomerSpecialPricePackage extends Model
{
protected $table = 'customer_special_price_packages';
protected $fillable = [
'title',
'profit_buy',
'profit_sell',
'show_product',
'is_active',
];
protected $casts = [
'profit_buy' => 'integer',
'profit_sell' => 'integer',
'show_product' => 'boolean',
'is_active' => 'boolean',
];
public function customers(): BelongsToMany
{
return $this->belongsToMany(
Customer::class,
'customer_special_price_package_customer',
'package_id',
'customer_id'
)->withPivot([]);
}
public function products(): BelongsToMany
{
return $this->belongsToMany(
Product::class,
'customer_special_price_package_product',
'package_id',
'product_id'
)->withPivot([]);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}
@@ -0,0 +1,287 @@
<?php
namespace App\Services;
use App\Models\CustomerSpecialPricePackage;
use App\Models\Product;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* قیمت ویژه مشتری — افزون بر buy_fee/sell_fee و cash_fee محصول.
* تداخل: آخرین پکیج (updated_at، سپس id).
*/
class CustomerSpecialPricingService
{
public const SETTING_KEY = 'customer_special_pricing';
public function tablesReady(): bool
{
return Schema::hasTable('customer_special_price_packages')
&& Schema::hasTable('customer_special_price_package_customer')
&& Schema::hasTable('customer_special_price_package_product');
}
public function isEnabled(): bool
{
if (! $this->tablesReady()) {
return false;
}
return app(ConfigService::class)->getBool(self::SETTING_KEY, false);
}
/**
* @return array{package_id:int,title:string,profit_buy:int,profit_sell:int,show_product:bool}|null
*/
public function ruleForCustomerProduct(int $customerId, int $productId): ?array
{
if (! $this->isEnabled() || $customerId <= 0 || $productId <= 0) {
return null;
}
$row = CustomerSpecialPricePackage::query()
->active()
->whereHas('customers', fn ($q) => $q->where('customers.id', $customerId))
->whereHas('products', fn ($q) => $q->where('products.id', $productId))
->orderByDesc('updated_at')
->orderByDesc('id')
->first(['id', 'title', 'profit_buy', 'profit_sell', 'show_product', 'updated_at']);
if (! $row) {
return null;
}
return [
'package_id' => (int) $row->id,
'title' => (string) $row->title,
'profit_buy' => (int) ($row->profit_buy ?? 0),
'profit_sell' => (int) ($row->profit_sell ?? 0),
'show_product' => (bool) $row->show_product,
];
}
/**
* قوانین مؤثر برای مشتری — هر محصول یک قانون (آخرین پکیج).
*
* @return array<int, array{package_id:int,title:string,profit_buy:int,profit_sell:int,show_product:bool}>
*/
public function rulesMapForCustomer(int $customerId): array
{
if (! $this->isEnabled() || $customerId <= 0) {
return [];
}
$packages = CustomerSpecialPricePackage::query()
->active()
->whereHas('customers', fn ($q) => $q->where('customers.id', $customerId))
->with(['products:id'])
->orderByDesc('updated_at')
->orderByDesc('id')
->get(['id', 'title', 'profit_buy', 'profit_sell', 'show_product', 'updated_at']);
$map = [];
foreach ($packages as $package) {
foreach ($package->products as $product) {
$pid = (int) $product->id;
if (isset($map[$pid])) {
continue;
}
$map[$pid] = [
'package_id' => (int) $package->id,
'title' => (string) $package->title,
'profit_buy' => (int) ($package->profit_buy ?? 0),
'profit_sell' => (int) ($package->profit_sell ?? 0),
'show_product' => (bool) $package->show_product,
];
}
}
return $map;
}
public function profitForDirection(?array $rule, int $direction): int
{
if (! $rule) {
return 0;
}
return $direction === 2
? (int) ($rule['profit_sell'] ?? 0)
: (int) ($rule['profit_buy'] ?? 0);
}
/**
* اعمال روی payload تابلو last (buy/sell/price_last) برای محصول اصلی یا اولین قانون آبشده.
*/
public function applyToLastPayload(array $payload, int $customerId): array
{
if (! $this->isEnabled() || $customerId <= 0) {
return $payload;
}
$mainId = app(ConfigService::class)->mainProductId();
$rule = null;
if ($mainId) {
$rule = $this->ruleForCustomerProduct($customerId, (int) $mainId);
}
if (! $rule) {
$map = $this->rulesMapForCustomer($customerId);
$rule = $map ? reset($map) : null;
if (is_array($rule) && ! ($rule['show_product'] ?? true)) {
// فقط برای اختلاف قیمت؛ مخفی‌سازی روی لیست محصول است
}
}
if (! $rule) {
return $payload;
}
$buyExtra = (int) ($rule['profit_buy'] ?? 0);
$sellExtra = (int) ($rule['profit_sell'] ?? 0);
if (isset($payload['buy']) && (int) $payload['buy'] > 0 && $buyExtra > 0) {
$payload['buy'] = (int) $payload['buy'] + $buyExtra;
}
if (isset($payload['sell']) && (int) $payload['sell'] > 0 && $sellExtra > 0) {
$payload['sell'] = max(0, (int) $payload['sell'] - $sellExtra);
}
if (isset($payload['price_last']) && (int) $payload['price_last'] > 0 && $buyExtra > 0) {
$payload['price_last'] = (int) $payload['price_last'] + $buyExtra;
}
if (isset($payload['priceLast']) && (int) $payload['priceLast'] > 0 && $buyExtra > 0) {
$payload['priceLast'] = (int) $payload['priceLast'] + $buyExtra;
}
$payload['special_pricing'] = [
'package_id' => $rule['package_id'],
'title' => $rule['title'],
'profit_buy' => $buyExtra,
'profit_sell' => $sellExtra,
];
return $payload;
}
/**
* روی کالکشن محصول: ویژه را روی مدل می‌نشاند و در صورت نیاز show را false می‌کند.
*
* @param Collection<int, Product>|iterable<Product> $products
*/
public function applyToProducts($products, int $customerId): void
{
if (! $this->isEnabled() || $customerId <= 0) {
return;
}
$map = $this->rulesMapForCustomer($customerId);
if ($map === []) {
return;
}
$collection = $products instanceof Collection ? $products : collect($products);
foreach ($collection as $product) {
if (! $product instanceof Product) {
continue;
}
$pid = (int) ($product->id ?? 0);
$rule = $map[$pid] ?? null;
if (! $rule) {
continue;
}
$product->setAttribute('special_profit_buy', (int) $rule['profit_buy']);
$product->setAttribute('special_profit_sell', (int) $rule['profit_sell']);
$product->setAttribute('special_package_id', (int) $rule['package_id']);
$product->setAttribute('special_package_title', (string) $rule['title']);
if (! ($rule['show_product'] ?? true)) {
$product->setAttribute('show', false);
}
}
}
/**
* هشدار تداخل: مشتری+محصول در پکیج دیگری هم هست.
*
* @param list<int> $customerIds
* @param list<int> $productIds
* @return list<string>
*/
public function overlapWarnings(array $customerIds, array $productIds, ?int $excludePackageId = null): array
{
if (! $this->tablesReady() || $customerIds === [] || $productIds === []) {
return [];
}
$customerIds = array_values(array_unique(array_map('intval', $customerIds)));
$productIds = array_values(array_unique(array_map('intval', $productIds)));
$q = DB::table('customer_special_price_packages as p')
->join('customer_special_price_package_customer as pc', 'pc.package_id', '=', 'p.id')
->join('customer_special_price_package_product as pp', 'pp.package_id', '=', 'p.id')
->join('customers as c', 'c.id', '=', 'pc.customer_id')
->join('products as pr', 'pr.id', '=', 'pp.product_id')
->where('p.is_active', true)
->whereIn('pc.customer_id', $customerIds)
->whereIn('pp.product_id', $productIds)
->select([
'p.id as package_id',
'p.title as package_title',
'c.id as customer_id',
'c.name as customer_name',
'pr.id as product_id',
'pr.title as product_title',
]);
if ($excludePackageId) {
$q->where('p.id', '!=', $excludePackageId);
}
$rows = $q->limit(50)->get();
if ($rows->isEmpty()) {
return [];
}
$warnings = [];
foreach ($rows as $row) {
$warnings[] = sprintf(
'تداخل: مشتری «%s» (#%d) و محصول «%s» (#%d) در پکیج «%s» (#%d) هم هست — آخرین پکیج اعمال می‌شود.',
$row->customer_name ?: '-',
(int) $row->customer_id,
$row->product_title ?: '-',
(int) $row->product_id,
$row->package_title ?: '-',
(int) $row->package_id
);
}
return $warnings;
}
/** payload سبک برای SSE / me */
public function clientPayloadForCustomer(int $customerId): array
{
$map = $this->rulesMapForCustomer($customerId);
$products = [];
foreach ($map as $productId => $rule) {
$products[] = array_merge(['product_id' => (int) $productId], $rule);
}
$mainId = app(ConfigService::class)->mainProductId();
$board = null;
if ($mainId && isset($map[(int) $mainId])) {
$board = $map[(int) $mainId];
} elseif ($products !== []) {
$board = $products[0];
}
return [
'enabled' => $this->isEnabled(),
'products' => $products,
'board' => $board,
];
}
}