added accounting failing handles
This commit is contained in:
@@ -850,6 +850,15 @@ class ProductController extends SuperController
|
||||
$data['description'] = $desc === '' ? null : $desc;
|
||||
}
|
||||
|
||||
if (!Product::hasKaratColumn()) {
|
||||
unset($data['karat']);
|
||||
} elseif (array_key_exists('karat', $data)) {
|
||||
$karat = is_string($data['karat']) || is_numeric($data['karat'])
|
||||
? trim((string) $data['karat'])
|
||||
: '';
|
||||
$data['karat'] = $karat === '' ? null : mb_substr($karat, 0, 32);
|
||||
}
|
||||
|
||||
if (!Product::hasOverrideTradingHoursColumn()) {
|
||||
unset($data['override_trading_hours']);
|
||||
} elseif (array_key_exists('override_trading_hours', $data)) {
|
||||
|
||||
@@ -41,7 +41,19 @@ class TahesabController extends Controller
|
||||
|
||||
public function table(Request $request)
|
||||
{
|
||||
$view = (string) $request->input('view', 'all');
|
||||
$query = Tahesab::with('customer')->orderByDesc('id');
|
||||
|
||||
if ($view === 'ok') {
|
||||
$query->whereNotNull('tahesab_id')->where('tahesab_id', '!=', '');
|
||||
} elseif ($view === 'failed') {
|
||||
$query->where(function ($q) {
|
||||
$q->where(function ($inner) {
|
||||
$inner->whereNull('tahesab_id')->orWhere('tahesab_id', '');
|
||||
})->orWhere('sync_status', 'failed');
|
||||
});
|
||||
}
|
||||
|
||||
\App\DB\Te\TableEngine::create($query, $request)
|
||||
->initSearch(['desc', 'type'])
|
||||
->select(['*'])
|
||||
@@ -392,6 +404,46 @@ class TahesabController extends Controller
|
||||
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
|
||||
}
|
||||
|
||||
public function pendingSummary(Request $request)
|
||||
{
|
||||
$customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null;
|
||||
|
||||
return response()->json($this->sync()->pendingSummary($customerId));
|
||||
}
|
||||
|
||||
public function pendingDocs(Request $request)
|
||||
{
|
||||
$customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null;
|
||||
$limit = max(1, min(500, (int) $request->input('limit', 200)));
|
||||
|
||||
return response()->json([
|
||||
'rows' => $this->sync()->pendingDocs($customerId, $limit),
|
||||
'summary' => $this->sync()->pendingSummary($customerId),
|
||||
]);
|
||||
}
|
||||
|
||||
public function syncOne(Request $request)
|
||||
{
|
||||
$type = (string) $request->input('type', '');
|
||||
$id = (int) $request->input('id', 0);
|
||||
if ($id <= 0 || ! in_array($type, ['row', 'withdraw', 'order'], true)) {
|
||||
return response()->json(['ok' => false, 'message' => 'پارامتر نامعتبر'], 422);
|
||||
}
|
||||
|
||||
$result = $this->sync()->syncOne($type, $id);
|
||||
|
||||
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
|
||||
}
|
||||
|
||||
public function syncAllPending(Request $request)
|
||||
{
|
||||
$limit = max(1, min(200, (int) $request->input('limit', 80)));
|
||||
$customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null;
|
||||
$result = $this->sync()->syncAllPending($limit, $customerId);
|
||||
|
||||
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
|
||||
}
|
||||
|
||||
public function registerCustomer($customerId)
|
||||
{
|
||||
$customer = Customer::findOrFail($customerId);
|
||||
|
||||
@@ -861,7 +861,19 @@ class ZarinController extends Controller
|
||||
|
||||
public function table(Request $request)
|
||||
{
|
||||
$view = (string) $request->input('view', 'all');
|
||||
$query = Zarin::with('customer')->orderByDesc('id');
|
||||
|
||||
if ($view === 'ok') {
|
||||
$query->whereNotNull('zarin_id')->where('zarin_id', '!=', '');
|
||||
} elseif ($view === 'failed') {
|
||||
$query->where(function ($q) {
|
||||
$q->where(function ($inner) {
|
||||
$inner->whereNull('zarin_id')->orWhere('zarin_id', '');
|
||||
})->orWhere('sync_status', 'failed');
|
||||
});
|
||||
}
|
||||
|
||||
\App\DB\Te\TableEngine::create($query, $request)
|
||||
->initSearch(['desc', 'type'])
|
||||
->select(['*'])
|
||||
@@ -1331,6 +1343,46 @@ class ZarinController extends Controller
|
||||
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
|
||||
}
|
||||
|
||||
public function pendingSummary(Request $request)
|
||||
{
|
||||
$customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null;
|
||||
|
||||
return response()->json($this->sync()->pendingSummary($customerId));
|
||||
}
|
||||
|
||||
public function pendingDocs(Request $request)
|
||||
{
|
||||
$customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null;
|
||||
$limit = max(1, min(500, (int) $request->input('limit', 200)));
|
||||
|
||||
return response()->json([
|
||||
'rows' => $this->sync()->pendingDocs($customerId, $limit),
|
||||
'summary' => $this->sync()->pendingSummary($customerId),
|
||||
]);
|
||||
}
|
||||
|
||||
public function syncOne(Request $request)
|
||||
{
|
||||
$type = (string) $request->input('type', '');
|
||||
$id = (int) $request->input('id', 0);
|
||||
if ($id <= 0 || ! in_array($type, ['row', 'withdraw', 'order'], true)) {
|
||||
return response()->json(['ok' => false, 'message' => 'پارامتر نامعتبر'], 422);
|
||||
}
|
||||
|
||||
$result = $this->sync()->syncOne($type, $id);
|
||||
|
||||
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
|
||||
}
|
||||
|
||||
public function syncAllPending(Request $request)
|
||||
{
|
||||
$limit = max(1, min(200, (int) $request->input('limit', 80)));
|
||||
$customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null;
|
||||
$result = $this->sync()->syncAllPending($limit, $customerId);
|
||||
|
||||
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
|
||||
}
|
||||
|
||||
public function registerCustomer($customerId)
|
||||
{
|
||||
$customer = Customer::findOrFail($customerId);
|
||||
|
||||
@@ -30,6 +30,7 @@ class Product extends Model
|
||||
private static ?bool $cashFeePercentModeColumnsExist = null;
|
||||
private static ?bool $physicalDeliveryColumnsExist = null;
|
||||
private static ?bool $descriptionColumnExists = null;
|
||||
private static ?bool $karatColumnExists = null;
|
||||
private static ?bool $tvPriceFormulaColumnsExist = null;
|
||||
private static ?bool $priceFormulaColumnsExist = null;
|
||||
|
||||
@@ -73,6 +74,9 @@ class Product extends Model
|
||||
if (!static::hasDescriptionColumn()) {
|
||||
unset($product->attributes['description']);
|
||||
}
|
||||
if (!static::hasKaratColumn()) {
|
||||
unset($product->attributes['karat']);
|
||||
}
|
||||
if (!static::hasOverrideTradingHoursColumn()) {
|
||||
unset($product->attributes['override_trading_hours']);
|
||||
}
|
||||
@@ -407,6 +411,24 @@ class Product extends Model
|
||||
return static::$descriptionColumnExists;
|
||||
}
|
||||
|
||||
public static function hasKaratColumn(): bool
|
||||
{
|
||||
if (static::$karatColumnExists !== null) {
|
||||
return static::$karatColumnExists;
|
||||
}
|
||||
|
||||
try {
|
||||
static::$karatColumnExists = Schema::hasColumn(
|
||||
(new static)->getTable(),
|
||||
'karat'
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
static::$karatColumnExists = false;
|
||||
}
|
||||
|
||||
return static::$karatColumnExists;
|
||||
}
|
||||
|
||||
public function orderLimitsEnabled(): bool
|
||||
{
|
||||
if (!static::hasOrderLimitColumns() || !($this->order_limit_enabled ?? false)) {
|
||||
@@ -510,6 +532,7 @@ class Product extends Model
|
||||
'bubble',
|
||||
'inventory',
|
||||
'description',
|
||||
'karat',
|
||||
'direct_price',
|
||||
'app_price', // روشن: قیمت واحد از قیمت زندهٔ اپ هر گرم × وزن محاسبه میشود
|
||||
'price_formula_enabled',
|
||||
|
||||
@@ -205,6 +205,393 @@ class TahesabSyncService
|
||||
&& trim($config->getString('tahesab_db', '')) !== '';
|
||||
}
|
||||
|
||||
/** ردیفهای محلی که هنوز در تهحساب ثبت نشدهاند */
|
||||
public function unsyncedRowsQuery()
|
||||
{
|
||||
return Tahesab::query()->where(function ($q) {
|
||||
$q->where(function ($inner) {
|
||||
$inner->whereNull('tahesab_id')->orWhere('tahesab_id', '');
|
||||
})->orWhere('sync_status', self::STATUS_FAILED);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* خلاصهٔ سندهای جاافتاده / ناموفق برای بنر ادمین.
|
||||
*
|
||||
* @return array{failed_rows:int,missing_withdraws:int,missing_orders:int,total:int}
|
||||
*/
|
||||
public function pendingSummary(?int $customerId = null): array
|
||||
{
|
||||
$failedRows = (int) $this->unsyncedRowsQuery()
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->count();
|
||||
$missingWithdraws = count($this->missingWithdrawIds(200, $customerId));
|
||||
$missingOrders = count($this->missingOrderIds(200, $customerId));
|
||||
|
||||
return [
|
||||
'failed_rows' => $failedRows,
|
||||
'missing_withdraws' => $missingWithdraws,
|
||||
'missing_orders' => $missingOrders,
|
||||
'total' => $failedRows + $missingWithdraws + $missingOrders,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* لیست یکپارچهٔ خطا خورده + جاافتاده برای تب ادمین.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function pendingDocs(?int $customerId = null, int $limit = 200): array
|
||||
{
|
||||
$limit = max(1, min(500, $limit));
|
||||
$rows = [];
|
||||
|
||||
$local = $this->unsyncedRowsQuery()
|
||||
->with('customer')
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->orderByDesc('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
foreach ($local as $row) {
|
||||
$arr = $row->toArray();
|
||||
$arr['issue_kind'] = 'failed';
|
||||
$arr['sync_target'] = ['type' => 'row', 'id' => (int) $row->id];
|
||||
$rows[] = $arr;
|
||||
}
|
||||
|
||||
$remaining = $limit - count($rows);
|
||||
if ($remaining > 0) {
|
||||
$withdrawIds = $this->missingWithdrawIds($remaining, $customerId);
|
||||
if ($withdrawIds !== []) {
|
||||
$withdraws = \App\Models\Withdraw::query()
|
||||
->with('customer')
|
||||
->whereIn('id', $withdrawIds)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
foreach ($withdraws as $w) {
|
||||
$cash = (float) ($w->cash ?? 0);
|
||||
$gold = (float) ($w->gold ?? 0);
|
||||
$isGold = abs($gold) > 1e-9;
|
||||
$rows[] = [
|
||||
'id' => null,
|
||||
'customer_id' => $w->customer_id,
|
||||
'customer' => $w->customer,
|
||||
'amount' => $isGold ? abs($gold) : abs($cash),
|
||||
'type' => $isGold ? 'طلا' : 'ريال',
|
||||
'desc' => 'شارژ/برداشت بدون سند محلی #'.$w->id.($w->note ? ' — '.$w->note : ''),
|
||||
'withdraw_id' => (int) $w->id,
|
||||
'order_id' => null,
|
||||
'sync_status' => 'missing',
|
||||
'sync_log' => 'سند در تهحساب ساخته نشده (جاافتاده)',
|
||||
'tahesab_id' => null,
|
||||
'created_at' => optional($w->created_at)?->toDateTimeString(),
|
||||
'issue_kind' => 'missing',
|
||||
'sync_target' => ['type' => 'withdraw', 'id' => (int) $w->id],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$remaining = $limit - count($rows);
|
||||
if ($remaining > 0) {
|
||||
$orderIds = $this->missingOrderIds($remaining, $customerId);
|
||||
if ($orderIds !== []) {
|
||||
$orders = \App\Models\Order::query()
|
||||
->with(['customer', 'product'])
|
||||
->whereIn('id', $orderIds)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
foreach ($orders as $order) {
|
||||
$direction = (int) ($order->direction ?? 0);
|
||||
$dirLabel = $direction === 1 ? 'خرید' : ($direction === 2 ? 'فروش' : 'سفارش');
|
||||
$productTitle = $order->product->title ?? 'محصول';
|
||||
$rows[] = [
|
||||
'id' => null,
|
||||
'customer_id' => $order->customer_id,
|
||||
'customer' => $order->customer,
|
||||
'amount' => (float) ($order->amount ?? 0),
|
||||
'type' => 'طلا',
|
||||
'desc' => $dirLabel.' '.$productTitle.' بدون سند محلی #'.$order->id,
|
||||
'withdraw_id' => null,
|
||||
'order_id' => (int) $order->id,
|
||||
'sync_status' => 'missing',
|
||||
'sync_log' => 'سند در تهحساب ساخته نشده (جاافتاده)',
|
||||
'tahesab_id' => null,
|
||||
'created_at' => optional($order->pay_at ?? $order->created_at)?->toDateTimeString(),
|
||||
'issue_kind' => 'missing',
|
||||
'sync_target' => ['type' => 'order', 'id' => (int) $order->id],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usort($rows, function ($a, $b) {
|
||||
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
|
||||
});
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* ثبت یک مورد: ردیف محلی / شارژ جاافتاده / سفارش جاافتاده.
|
||||
*
|
||||
* @return array{ok:bool,message:string}
|
||||
*/
|
||||
public function syncOne(string $type, int $id): array
|
||||
{
|
||||
if (! $this->isSyncEnabled()) {
|
||||
return ['ok' => false, 'message' => 'همگامسازی تهحساب فعال نیست یا تنظیمات ناقص است'];
|
||||
}
|
||||
|
||||
$controller = app(TahesabController::class);
|
||||
|
||||
try {
|
||||
if ($type === 'row') {
|
||||
$row = Tahesab::with('customer')->findOrFail($id);
|
||||
|
||||
return $this->retry($row);
|
||||
}
|
||||
if ($type === 'withdraw') {
|
||||
$ok = $this->backfillWithdraw($controller, $id);
|
||||
|
||||
return ['ok' => $ok, 'message' => $ok ? 'سند شارژ/برداشت ثبت شد' : 'ارسال شارژ/برداشت ناموفق بود'];
|
||||
}
|
||||
if ($type === 'order') {
|
||||
$ok = $this->backfillOrder($controller, $id);
|
||||
|
||||
return ['ok' => $ok, 'message' => $ok ? 'سند سفارش ثبت شد' : 'ارسال سفارش ناموفق بود'];
|
||||
}
|
||||
|
||||
return ['ok' => false, 'message' => 'نوع نامعتبر است'];
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return ['ok' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ارسال مجدد همهٔ ناموفقها + ساخت و ارسال سندهای جاافتاده از شارژ/سفارش.
|
||||
*
|
||||
* @return array{ok:bool,retried:int,backfilled_withdraws:int,backfilled_orders:int,failed:int,errors:list<string>,summary:array}
|
||||
*/
|
||||
public function syncAllPending(int $limit = 80, ?int $customerId = null): array
|
||||
{
|
||||
if (! $this->isSyncEnabled()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'retried' => 0,
|
||||
'backfilled_withdraws' => 0,
|
||||
'backfilled_orders' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => ['همگامسازی تهحساب فعال نیست یا تنظیمات ناقص است'],
|
||||
'summary' => $this->pendingSummary($customerId),
|
||||
];
|
||||
}
|
||||
|
||||
$retried = 0;
|
||||
$backfilledWithdraws = 0;
|
||||
$backfilledOrders = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
$controller = app(TahesabController::class);
|
||||
$used = 0;
|
||||
|
||||
$rows = $this->unsyncedRowsQuery()
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
foreach ($rows as $row) {
|
||||
$used++;
|
||||
try {
|
||||
$result = $this->retry($row);
|
||||
if ($result['ok'] ?? false) {
|
||||
$retried++;
|
||||
} else {
|
||||
$failed++;
|
||||
$errors[] = 'سند #'.$row->id.': '.($result['message'] ?? 'ناموفق');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$errors[] = 'سند #'.$row->id.': '.$e->getMessage();
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->missingWithdrawIds(max(0, $limit - $used), $customerId) as $withdrawId) {
|
||||
$used++;
|
||||
try {
|
||||
$ok = $this->backfillWithdraw($controller, (int) $withdrawId);
|
||||
if ($ok) {
|
||||
$backfilledWithdraws++;
|
||||
} else {
|
||||
$failed++;
|
||||
$errors[] = 'شارژ/برداشت #'.$withdrawId.': ارسال به تهحساب ناموفق';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$errors[] = 'شارژ/برداشت #'.$withdrawId.': '.$e->getMessage();
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->missingOrderIds(max(0, $limit - $used), $customerId) as $orderId) {
|
||||
$used++;
|
||||
try {
|
||||
$ok = $this->backfillOrder($controller, (int) $orderId);
|
||||
if ($ok) {
|
||||
$backfilledOrders++;
|
||||
} else {
|
||||
$failed++;
|
||||
$errors[] = 'سفارش #'.$orderId.': ارسال به تهحساب ناموفق';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$errors[] = 'سفارش #'.$orderId.': '.$e->getMessage();
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $failed === 0,
|
||||
'retried' => $retried,
|
||||
'backfilled_withdraws' => $backfilledWithdraws,
|
||||
'backfilled_orders' => $backfilledOrders,
|
||||
'failed' => $failed,
|
||||
'errors' => array_slice($errors, 0, 20),
|
||||
'summary' => $this->pendingSummary($customerId),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function missingWithdrawIds(int $limit, ?int $customerId = null): array
|
||||
{
|
||||
if ($limit <= 0 || ! Schema::hasTable('withdraws')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$linked = Tahesab::query()
|
||||
->whereNotNull('withdraw_id')
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->pluck('withdraw_id')
|
||||
->all();
|
||||
|
||||
return \App\Models\Withdraw::query()
|
||||
->where('sts', 4)
|
||||
->where(function ($q) {
|
||||
$q->where('cash', '!=', 0)->orWhere('gold', '!=', 0);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('note')
|
||||
->orWhere('note', 'not like', 'fix:intval-gold-loss%');
|
||||
})
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->when($linked !== [], fn ($q) => $q->whereNotIn('id', $linked))
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function missingOrderIds(int $limit, ?int $customerId = null): array
|
||||
{
|
||||
if ($limit <= 0 || ! Schema::hasTable('orders')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$linked = Tahesab::query()
|
||||
->whereNotNull('order_id')
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->pluck('order_id')
|
||||
->all();
|
||||
|
||||
return \App\Models\Order::query()
|
||||
->where('sts', 2)
|
||||
->where('payment_sts', 4)
|
||||
->whereIn('direction', [1, 2])
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->when($linked !== [], fn ($q) => $q->whereNotIn('id', $linked))
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function backfillWithdraw(TahesabController $controller, int $withdrawId): bool
|
||||
{
|
||||
$withdraw = \App\Models\Withdraw::query()->find($withdrawId);
|
||||
if (! $withdraw || ! $withdraw->customer_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cash = (float) ($withdraw->cash ?? 0);
|
||||
$gold = (float) ($withdraw->gold ?? 0);
|
||||
|
||||
if (abs($cash) > 1e-9) {
|
||||
$result = $controller->wallet([
|
||||
'customer_id' => (int) $withdraw->customer_id,
|
||||
'value' => abs($cash),
|
||||
'direction' => $cash >= 0 ? 0 : 1,
|
||||
'withdraw_id' => $withdrawId,
|
||||
'note' => $withdraw->note,
|
||||
]);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
if (abs($gold) > 1e-9) {
|
||||
$result = $controller->walletGold([
|
||||
'customer_id' => (int) $withdraw->customer_id,
|
||||
'value' => abs($gold),
|
||||
'direction' => $gold >= 0 ? 1 : 0,
|
||||
'withdraw_id' => $withdrawId,
|
||||
'note' => $withdraw->note,
|
||||
]);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function backfillOrder(TahesabController $controller, int $orderId): bool
|
||||
{
|
||||
$order = \App\Models\Order::query()->with('product')->find($orderId);
|
||||
if (! $order || ! $order->customer_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$product = $order->product;
|
||||
$payload = [
|
||||
'customer_id' => (int) $order->customer_id,
|
||||
'amount' => (float) $order->amount,
|
||||
'direction' => (int) $order->direction,
|
||||
'price' => (float) $order->price,
|
||||
'total_price' => (float) $order->total_price,
|
||||
'pay_at' => $order->pay_at ?? $order->created_at,
|
||||
'product_id' => $order->product_id ? (int) $order->product_id : null,
|
||||
'product' => $product ? [
|
||||
'id' => (int) $product->id,
|
||||
'title' => $product->title,
|
||||
'group_type' => $product->group_type,
|
||||
'metal_id' => $product->metal_id,
|
||||
'amount' => $product->amount,
|
||||
'unit' => $product->unit,
|
||||
'trade_by_quantity' => $product->trade_by_quantity,
|
||||
] : ['metal_id' => 1],
|
||||
'order' => ['id' => $orderId, 'product_id' => $order->product_id],
|
||||
];
|
||||
|
||||
$result = $controller->storeTrade($payload);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
private function hasSyncColumns(): bool
|
||||
{
|
||||
return Schema::hasTable('tahesab') && Schema::hasColumn('tahesab', 'sync_status');
|
||||
|
||||
@@ -258,6 +258,384 @@ class ZarinSyncService
|
||||
&& trim($config->getString('zarin_token', '')) !== '';
|
||||
}
|
||||
|
||||
public function unsyncedRowsQuery()
|
||||
{
|
||||
return Zarin::query()->where(function ($q) {
|
||||
$q->where(function ($inner) {
|
||||
$inner->whereNull('zarin_id')->orWhere('zarin_id', '');
|
||||
})->orWhere('sync_status', self::STATUS_FAILED);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{failed_rows:int,missing_withdraws:int,missing_orders:int,total:int}
|
||||
*/
|
||||
public function pendingSummary(?int $customerId = null): array
|
||||
{
|
||||
$failedRows = (int) $this->unsyncedRowsQuery()
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->count();
|
||||
$missingWithdraws = count($this->missingWithdrawIds(200, $customerId));
|
||||
$missingOrders = count($this->missingOrderIds(200, $customerId));
|
||||
|
||||
return [
|
||||
'failed_rows' => $failedRows,
|
||||
'missing_withdraws' => $missingWithdraws,
|
||||
'missing_orders' => $missingOrders,
|
||||
'total' => $failedRows + $missingWithdraws + $missingOrders,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function pendingDocs(?int $customerId = null, int $limit = 200): array
|
||||
{
|
||||
$limit = max(1, min(500, $limit));
|
||||
$rows = [];
|
||||
|
||||
$local = $this->unsyncedRowsQuery()
|
||||
->with('customer')
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->orderByDesc('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
foreach ($local as $row) {
|
||||
$arr = $row->toArray();
|
||||
$arr['issue_kind'] = 'failed';
|
||||
$arr['sync_target'] = ['type' => 'row', 'id' => (int) $row->id];
|
||||
$rows[] = $arr;
|
||||
}
|
||||
|
||||
$remaining = $limit - count($rows);
|
||||
if ($remaining > 0) {
|
||||
$withdrawIds = $this->missingWithdrawIds($remaining, $customerId);
|
||||
if ($withdrawIds !== []) {
|
||||
$withdraws = \App\Models\Withdraw::query()
|
||||
->with('customer')
|
||||
->whereIn('id', $withdrawIds)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
foreach ($withdraws as $w) {
|
||||
$cash = (float) ($w->cash ?? 0);
|
||||
$gold = (float) ($w->gold ?? 0);
|
||||
$isGold = abs($gold) > 1e-9;
|
||||
$rows[] = [
|
||||
'id' => null,
|
||||
'customer_id' => $w->customer_id,
|
||||
'customer' => $w->customer,
|
||||
'amount' => $isGold ? abs($gold) : abs($cash),
|
||||
'type' => $isGold ? 'طلا' : 'ريال',
|
||||
'desc' => 'شارژ/برداشت بدون سند محلی #'.$w->id.($w->note ? ' — '.$w->note : ''),
|
||||
'withdraw_id' => (int) $w->id,
|
||||
'order_id' => null,
|
||||
'sync_status' => 'missing',
|
||||
'sync_log' => 'سند در زرین ساخته نشده (جاافتاده)',
|
||||
'zarin_id' => null,
|
||||
'created_at' => optional($w->created_at)?->toDateTimeString(),
|
||||
'issue_kind' => 'missing',
|
||||
'sync_target' => ['type' => 'withdraw', 'id' => (int) $w->id],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$remaining = $limit - count($rows);
|
||||
if ($remaining > 0) {
|
||||
$orderIds = $this->missingOrderIds($remaining, $customerId);
|
||||
if ($orderIds !== []) {
|
||||
$orders = \App\Models\Order::query()
|
||||
->with(['customer', 'product'])
|
||||
->whereIn('id', $orderIds)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
foreach ($orders as $order) {
|
||||
$direction = (int) ($order->direction ?? 0);
|
||||
$dirLabel = $direction === 1 ? 'خرید' : ($direction === 2 ? 'فروش' : 'سفارش');
|
||||
$productTitle = $order->product->title ?? 'محصول';
|
||||
$rows[] = [
|
||||
'id' => null,
|
||||
'customer_id' => $order->customer_id,
|
||||
'customer' => $order->customer,
|
||||
'amount' => (float) ($order->amount ?? 0),
|
||||
'type' => 'طلا',
|
||||
'desc' => $dirLabel.' '.$productTitle.' بدون سند محلی #'.$order->id,
|
||||
'withdraw_id' => null,
|
||||
'order_id' => (int) $order->id,
|
||||
'sync_status' => 'missing',
|
||||
'sync_log' => 'سند در زرین ساخته نشده (جاافتاده)',
|
||||
'zarin_id' => null,
|
||||
'created_at' => optional($order->pay_at ?? $order->created_at)?->toDateTimeString(),
|
||||
'issue_kind' => 'missing',
|
||||
'sync_target' => ['type' => 'order', 'id' => (int) $order->id],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usort($rows, function ($a, $b) {
|
||||
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
|
||||
});
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,message:string}
|
||||
*/
|
||||
public function syncOne(string $type, int $id): array
|
||||
{
|
||||
if (! $this->isSyncEnabled()) {
|
||||
return ['ok' => false, 'message' => 'همگامسازی زرین فعال نیست یا تنظیمات ناقص است'];
|
||||
}
|
||||
|
||||
$controller = app(ZarinController::class);
|
||||
|
||||
try {
|
||||
if ($type === 'row') {
|
||||
$row = Zarin::with('customer')->findOrFail($id);
|
||||
|
||||
return $this->retry($row);
|
||||
}
|
||||
if ($type === 'withdraw') {
|
||||
$ok = $this->backfillWithdraw($controller, $id);
|
||||
|
||||
return ['ok' => $ok, 'message' => $ok ? 'سند شارژ/برداشت ثبت شد' : 'ارسال شارژ/برداشت ناموفق بود'];
|
||||
}
|
||||
if ($type === 'order') {
|
||||
$ok = $this->backfillOrder($controller, $id);
|
||||
|
||||
return ['ok' => $ok, 'message' => $ok ? 'سند سفارش ثبت شد' : 'ارسال سفارش ناموفق بود'];
|
||||
}
|
||||
|
||||
return ['ok' => false, 'message' => 'نوع نامعتبر است'];
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return ['ok' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,retried:int,backfilled_withdraws:int,backfilled_orders:int,failed:int,errors:list<string>,summary:array}
|
||||
*/
|
||||
public function syncAllPending(int $limit = 80, ?int $customerId = null): array
|
||||
{
|
||||
if (! $this->isSyncEnabled()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'retried' => 0,
|
||||
'backfilled_withdraws' => 0,
|
||||
'backfilled_orders' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => ['همگامسازی زرین فعال نیست یا تنظیمات ناقص است'],
|
||||
'summary' => $this->pendingSummary($customerId),
|
||||
];
|
||||
}
|
||||
|
||||
$retried = 0;
|
||||
$backfilledWithdraws = 0;
|
||||
$backfilledOrders = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
$controller = app(ZarinController::class);
|
||||
$used = 0;
|
||||
|
||||
$rows = $this->unsyncedRowsQuery()
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
foreach ($rows as $row) {
|
||||
$used++;
|
||||
try {
|
||||
$result = $this->retry($row);
|
||||
if ($result['ok'] ?? false) {
|
||||
$retried++;
|
||||
} else {
|
||||
$failed++;
|
||||
$errors[] = 'سند #'.$row->id.': '.($result['message'] ?? 'ناموفق');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$errors[] = 'سند #'.$row->id.': '.$e->getMessage();
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->missingWithdrawIds(max(0, $limit - $used), $customerId) as $withdrawId) {
|
||||
$used++;
|
||||
try {
|
||||
$ok = $this->backfillWithdraw($controller, (int) $withdrawId);
|
||||
if ($ok) {
|
||||
$backfilledWithdraws++;
|
||||
} else {
|
||||
$failed++;
|
||||
$errors[] = 'شارژ/برداشت #'.$withdrawId.': ارسال به زرین ناموفق';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$errors[] = 'شارژ/برداشت #'.$withdrawId.': '.$e->getMessage();
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->missingOrderIds(max(0, $limit - $used), $customerId) as $orderId) {
|
||||
$used++;
|
||||
try {
|
||||
$ok = $this->backfillOrder($controller, (int) $orderId);
|
||||
if ($ok) {
|
||||
$backfilledOrders++;
|
||||
} else {
|
||||
$failed++;
|
||||
$errors[] = 'سفارش #'.$orderId.': ارسال به زرین ناموفق';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$errors[] = 'سفارش #'.$orderId.': '.$e->getMessage();
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $failed === 0,
|
||||
'retried' => $retried,
|
||||
'backfilled_withdraws' => $backfilledWithdraws,
|
||||
'backfilled_orders' => $backfilledOrders,
|
||||
'failed' => $failed,
|
||||
'errors' => array_slice($errors, 0, 20),
|
||||
'summary' => $this->pendingSummary($customerId),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function missingWithdrawIds(int $limit, ?int $customerId = null): array
|
||||
{
|
||||
if ($limit <= 0 || ! Schema::hasTable('withdraws')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$linked = Zarin::query()
|
||||
->whereNotNull('withdraw_id')
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->pluck('withdraw_id')
|
||||
->all();
|
||||
|
||||
return \App\Models\Withdraw::query()
|
||||
->where('sts', 4)
|
||||
->where(function ($q) {
|
||||
$q->where('cash', '!=', 0)->orWhere('gold', '!=', 0);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('note')
|
||||
->orWhere('note', 'not like', 'fix:intval-gold-loss%');
|
||||
})
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->when($linked !== [], fn ($q) => $q->whereNotIn('id', $linked))
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function missingOrderIds(int $limit, ?int $customerId = null): array
|
||||
{
|
||||
if ($limit <= 0 || ! Schema::hasTable('orders')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$linked = Zarin::query()
|
||||
->whereNotNull('order_id')
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->pluck('order_id')
|
||||
->all();
|
||||
|
||||
return \App\Models\Order::query()
|
||||
->where('sts', 2)
|
||||
->where('payment_sts', 4)
|
||||
->whereIn('direction', [1, 2])
|
||||
->when($customerId, fn ($q) => $q->where('customer_id', $customerId))
|
||||
->when($linked !== [], fn ($q) => $q->whereNotIn('id', $linked))
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function backfillWithdraw(ZarinController $controller, int $withdrawId): bool
|
||||
{
|
||||
$withdraw = \App\Models\Withdraw::query()->find($withdrawId);
|
||||
if (! $withdraw || ! $withdraw->customer_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cash = (float) ($withdraw->cash ?? 0);
|
||||
$gold = (float) ($withdraw->gold ?? 0);
|
||||
|
||||
if (abs($cash) > 1e-9) {
|
||||
$result = $controller->wallet([
|
||||
'customer_id' => (int) $withdraw->customer_id,
|
||||
'value' => abs($cash),
|
||||
'direction' => $cash >= 0 ? 0 : 1,
|
||||
'withdraw_id' => $withdrawId,
|
||||
'note' => $withdraw->note,
|
||||
]);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
if (abs($gold) > 1e-9) {
|
||||
$result = $controller->walletGold([
|
||||
'customer_id' => (int) $withdraw->customer_id,
|
||||
'value' => abs($gold),
|
||||
'direction' => $gold >= 0 ? 1 : 0,
|
||||
'withdraw_id' => $withdrawId,
|
||||
'note' => $withdraw->note,
|
||||
]);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function backfillOrder(ZarinController $controller, int $orderId): bool
|
||||
{
|
||||
$order = \App\Models\Order::query()->with('product')->find($orderId);
|
||||
if (! $order || ! $order->customer_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$product = $order->product;
|
||||
$payload = [
|
||||
'customer_id' => (int) $order->customer_id,
|
||||
'amount' => (float) $order->amount,
|
||||
'direction' => (int) $order->direction,
|
||||
'price' => (float) $order->price,
|
||||
'total_price' => (float) $order->total_price,
|
||||
'pay_at' => $order->pay_at ?? $order->created_at,
|
||||
'product_id' => $order->product_id ? (int) $order->product_id : null,
|
||||
'product' => $product ? [
|
||||
'id' => (int) $product->id,
|
||||
'title' => $product->title,
|
||||
'group_type' => $product->group_type,
|
||||
'metal_id' => $product->metal_id,
|
||||
'amount' => $product->amount,
|
||||
'unit' => $product->unit,
|
||||
'trade_by_quantity' => $product->trade_by_quantity,
|
||||
] : ['metal_id' => 1],
|
||||
'order' => ['id' => $orderId, 'product_id' => $order->product_id],
|
||||
];
|
||||
|
||||
$result = $controller->storeTrade($payload);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
private function flattenResponse(array $response): array
|
||||
{
|
||||
$data = $response['data'] ?? null;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* عیار محصول برای نمایش در مشخصات اپ مشتری (اختیاری).
|
||||
* idempotent.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('products') || Schema::hasColumn('products', 'karat')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement(
|
||||
'ALTER TABLE `products` ADD COLUMN `karat` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL'
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// ستون را حذف نمیکنیم — ممکن است داده داشته باشد
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
-- عیار محصول (اختیاری) — idempotent
|
||||
-- اجرا روی DB tenant در صورت نیاز دستی
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @has_karat := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 'products'
|
||||
AND COLUMN_NAME = 'karat'
|
||||
);
|
||||
|
||||
SET @sql := IF(
|
||||
@has_karat = 0,
|
||||
'ALTER TABLE `products` ADD COLUMN `karat` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL',
|
||||
'SELECT 1'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -230,6 +230,10 @@ Route::middleware('auth:user')->group(function (){
|
||||
|
||||
Route::post('wallet/tahesab', [TahesabController::class, 'wallet']);
|
||||
Route::post('tahesab/table', [TahesabController::class, 'table']);
|
||||
Route::get('tahesab/pending-summary', [TahesabController::class, 'pendingSummary']);
|
||||
Route::get('tahesab/pending-docs', [TahesabController::class, 'pendingDocs']);
|
||||
Route::post('tahesab/sync-one', [TahesabController::class, 'syncOne']);
|
||||
Route::post('tahesab/sync-all-pending', [TahesabController::class, 'syncAllPending']);
|
||||
Route::get('tahesab/customer-sync/status', [TahesabController::class, 'customerSyncStatus']);
|
||||
Route::post('tahesab/customer-sync/panel-to-tahesab', [TahesabController::class, 'syncPanelToTahesab']);
|
||||
Route::post('tahesab/customer-sync/import-from-tahesab', [TahesabController::class, 'importFromTahesab']);
|
||||
@@ -241,6 +245,10 @@ Route::middleware('auth:user')->group(function (){
|
||||
Route::get('zarin/health', [ZarinController::class, 'healthCheck']);
|
||||
Route::post('wallet/zarin', [ZarinController::class, 'wallet']);
|
||||
Route::post('zarin/table', [ZarinController::class, 'table']);
|
||||
Route::get('zarin/pending-summary', [ZarinController::class, 'pendingSummary']);
|
||||
Route::get('zarin/pending-docs', [ZarinController::class, 'pendingDocs']);
|
||||
Route::post('zarin/sync-one', [ZarinController::class, 'syncOne']);
|
||||
Route::post('zarin/sync-all-pending', [ZarinController::class, 'syncAllPending']);
|
||||
// status با query روی بعضی سرورها QUERY_STRING خالی است — مسیر جدا برای لیست زرین
|
||||
Route::get('zarin/customer-sync/status-with-zarin', [ZarinController::class, 'customerSyncStatusWithZarin']);
|
||||
Route::match(['get', 'post'], 'zarin/customer-sync/status', [ZarinController::class, 'customerSyncStatus']);
|
||||
|
||||
Reference in New Issue
Block a user