added accounting failing handles

This commit is contained in:
2026-08-27 16:16:20 +03:30
parent 93834c9f40
commit 6b5b490e2b
9 changed files with 959 additions and 0 deletions
@@ -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);
+23
View File
@@ -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',
+387
View File
@@ -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');
+378
View File
@@ -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
{
// ستون را حذف نمی‌کنیم — ممکن است داده داشته باشد
}
};
+22
View File
@@ -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;
+8
View File
@@ -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']);