Files
boursecu_server/app/Http/Controllers/Admin/ZarinController.php
T

2637 lines
92 KiB
PHP
Raw Normal View History

2026-07-23 20:24:58 +03:30
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Site\ReportController;
use App\Models\Customer;
use App\Models\Order;
use App\Models\Product;
use App\Models\Zarin;
2026-07-28 17:44:24 +03:30
use App\Services\AccountingProductDocMapper;
use App\Services\ConfigService;
use App\Services\ZarinInboundWalletService;
2026-07-23 20:24:58 +03:30
use App\Services\ZarinSyncService;
use Carbon\Carbon;
use Illuminate\Contracts\Cache\LockTimeoutException;
2026-07-23 20:24:58 +03:30
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
class ZarinController extends Controller
{
protected function sync(): ZarinSyncService
{
return app(ZarinSyncService::class);
}
private function lastPriceData(): array
{
$response = app(ReportController::class)->lastPrice(request());
if (is_array($response)) {
return $response;
}
if (is_object($response) && method_exists($response, 'getData')) {
return (array) $response->getData(true);
}
return (array) $response;
}
private function zarinConfigError()
{
return response()->json([
'message' => 'تنظیمات زرین کامل نیست. zarin_api یا zarin_token تنظیم نشده است.'
], 422);
}
private function zarinAccountingUrl()
{
$api = trim((string) $this->Config('zarin_api'));
if (empty($api)) {
return null;
}
return rtrim($api, '/') . '/ZarrinService/accounting';
}
private function zarinToken()
{
$token = trim((string) $this->Config('zarin_token'));
return empty($token) ? null : $token;
}
2026-07-28 14:02:23 +03:30
private function zarinRequest(array $params, int $timeout = 15)
2026-07-23 20:24:58 +03:30
{
$url = $this->zarinAccountingUrl();
$token = $this->zarinToken();
if (!$url || !$token) {
return null;
}
$params['token'] = $token;
return Http::withoutVerifying()
2026-07-28 14:02:23 +03:30
->timeout($timeout)
2026-07-23 20:24:58 +03:30
->get($url, $params);
}
2026-07-28 14:02:23 +03:30
/**
* decode پاسخ HTTP زرین — خطاهای متنی (مثل ERROR: radif is empty) را هم برمی‌گرداند.
*
* @return array{0: ?array, 1: ?string} [json, error]
*/
private function zarinDecodeResponse($http): array
{
if (!$http) {
return [null, 'تنظیمات zarin_api یا zarin_token خالی است.'];
}
$body = (string) $http->body();
$json = $http->json();
if (!is_array($json) && $body !== '') {
$decoded = json_decode($body, true);
if (is_array($decoded)) {
$json = $decoded;
}
}
if (!$http->successful()) {
$detail = null;
if (is_array($json)) {
$detail = $json['detail'] ?? $json['msg'] ?? $json['message'] ?? $json['error'] ?? null;
}
if ($detail === null || $detail === '') {
$detail = trim($body) !== '' ? mb_substr(trim($body), 0, 300) : null;
}
$error = 'HTTP ' . $http->status() . ($detail ? (': ' . $detail) : '');
return [is_array($json) ? $json : null, $error];
}
if (!is_array($json)) {
return [null, 'پاسخ زرین JSON نیست (طول ' . strlen($body) . ')'];
}
return [$json, null];
}
2026-07-23 20:24:58 +03:30
private function zarinDefaultNoeKar()
{
$value = (int) $this->Config('zarin_noe_kar');
return $value > 0 ? $value : 16;
}
2026-07-28 19:08:04 +03:30
/**
* نوع کار برای سند تعدادی (sabtSanadTedadi).
* روی زرین aygin: 16=گرمی (رد می‌شود)، 17=سکه-تعدادی، 18=اجرت تعدادی.
*/
private function zarinCountNoeKar(): int
{
$tedadi = (int) $this->Config('zarin_noe_kar_tedadi');
if (in_array($tedadi, [17, 18], true)) {
return $tedadi;
}
$configured = $this->zarinDefaultNoeKar();
if (in_array($configured, [17, 18], true)) {
return $configured;
}
// 16 و سایر مقادیر گرمی/نامرتبط برای sabtSanadTedadi قابل استفاده نیستند
return 17;
}
/** fkNoeهایی که sabtSanadTedadi قبول می‌کند */
private function zarinCountFkNoes(): array
{
return ['17', '18'];
}
2026-07-23 20:24:58 +03:30
private function zarinDefaultGoroheKar()
{
$value = (int) $this->Config('zarin_gorohe_kar');
return $value > 0 ? $value : 52;
}
2026-07-28 14:49:55 +03:30
/** نام گروه حساب مشتری در زرین — تنظیم zarin_name_g؛ خالی = گروه ۶ مشتری نقدی */
2026-07-23 20:24:58 +03:30
private function zarinCustomerGroupName(): string
{
2026-07-28 14:49:55 +03:30
$nameG = trim((string) $this->Config('zarin_name_g'));
return $nameG !== '' ? $nameG : 'مشتری نقدی (خریدار)';
}
2026-07-28 14:57:28 +03:30
/** آیا درخواست لیست زرین را خواسته؟ (query/body/header؛ مقاوم به QUERY_STRING خالی) */
2026-07-28 14:49:55 +03:30
private function requestWantsZarinList(Request $request): bool
{
2026-07-28 14:57:28 +03:30
$header = $request->header('X-Include-Zarin', $request->header('Include-Zarin'));
if ($header !== null && $header !== '') {
if (filter_var($header, FILTER_VALIDATE_BOOLEAN) || in_array($header, [1, '1', 'yes', 'on'], true)) {
return true;
}
}
2026-07-28 14:49:55 +03:30
foreach (['include_zarin', 'include_tahesab'] as $key) {
$raw = $request->query($key, $request->input($key, null));
if ($raw === null && !$request->query->has($key) && !$request->exists($key)) {
continue;
}
// ?include_zarin بدون مقدار
if ($raw === null || $raw === '' || $raw === []) {
return true;
}
if ($request->boolean($key)) {
return true;
}
if (in_array($raw, [1, '1', true, 'true', 'yes', 'on', 'TRUE', 'Yes'], true)) {
return true;
}
}
return false;
}
/** مقایسه نام گروه زرین (ی/ک، پرانتز/آندرلاین، فاصله) */
private function zarinGroupNamesMatch(string $a, string $b): bool
{
$norm = function (string $s): string {
$s = $this->normalizeZarinName($s);
$s = str_replace(['(', ')', '_', '-', '–', '—'], ' ', $s);
return trim(preg_replace('/\s+/u', ' ', $s) ?? $s);
};
$na = $norm($a);
$nb = $norm($b);
if ($na === '' || $nb === '') {
return false;
}
return $na === $nb
|| $this->containsText($na, $nb)
|| $this->containsText($nb, $na);
2026-07-23 20:24:58 +03:30
}
/**
* پیشوند عددی uid — وقتی چند پنل یک توکن زرین دارند باید متفاوت باشد
* تا uid تکراری و پاسخ Cache قاطی نشود (فقط ارقام، طبق داکیومنت زرین).
*/
private function zarinUid(string $kindPrefix, $localId): string
{
$panel = preg_replace('/\D+/', '', (string) $this->Config('zarin_uid_prefix')) ?: '';
$local = preg_replace('/\D+/', '', (string) $localId) ?: '0';
return $panel . $kindPrefix . $local;
}
private function isListArray(array $array)
{
if (function_exists('array_is_list')) {
return array_is_list($array);
}
return array_keys($array) === range(0, count($array) - 1);
}
private function containsText($haystack, $needle)
{
$haystack = (string) $haystack;
$needle = (string) $needle;
if ($needle === '') {
return false;
}
return mb_strpos($haystack, $needle) !== false;
}
private function normalizeZarinName($value)
{
$value = trim((string) $value);
$value = str_replace(['ي', 'ك', '‌'], ['ی', 'ک', ' '], $value);
$value = preg_replace('/\s+/u', ' ', $value);
return mb_strtolower($value);
}
private function zarinRowsFromBody($body)
{
if (!is_array($body)) {
return [];
}
$data = $body['data'] ?? $body['Data'] ?? [];
if (!is_array($data)) {
return [];
}
return $this->isListArray($data) ? $data : [$data];
}
private function extractResponseRow(?array $body): array
{
if (!is_array($body)) {
return [];
}
if (isset($body['data']) && is_array($body['data'])) {
$data = $this->isListArray($body['data'])
? ($body['data'][0] ?? [])
: $body['data'];
if (is_array($data)) {
return array_merge($body, $data);
}
}
return $body;
}
2026-07-28 14:02:23 +03:30
/**
* پیدا کردن کار/کالای محصول در زرین.
2026-07-28 18:55:59 +03:30
* اولویت: listKarSakhte (کاتالوگ کار ساخته) → infosakhte → infokarbarcodi.
2026-07-28 14:02:23 +03:30
*/
2026-07-28 18:40:06 +03:30
private function findZarinKarForProduct(Product $product, $goroheKar = null, bool $exactOnly = false)
2026-07-23 20:24:58 +03:30
{
$goroheKar = $goroheKar ?: $this->zarinDefaultGoroheKar();
$productName = $this->normalizeZarinName($product->title);
2026-07-28 19:08:04 +03:30
$fromList = $this->findZarinKarInListKarSakhte($productName, $exactOnly, true);
2026-07-28 18:55:59 +03:30
if ($fromList) {
return $fromList;
}
2026-07-28 18:40:06 +03:30
$fromBarcode = $this->findZarinKarInInfokarbarcodi($productName, $goroheKar, $exactOnly);
2026-07-28 14:02:23 +03:30
if ($fromBarcode) {
return $fromBarcode;
}
2026-07-28 18:40:06 +03:30
return $this->findZarinKarInInfosakhte($productName, $goroheKar, $exactOnly);
2026-07-28 14:02:23 +03:30
}
2026-07-28 18:55:59 +03:30
/**
* کاتالوگ کارهای ساخته (لیست ۴۷ زرین) — id همین idKar سند تعدادی است.
2026-07-28 19:08:04 +03:30
* برای سند تعدادی فقط fkNoe سکه-تعدادی/اجرت تعدادی (۱۷/۱۸) قبول می‌شود؛ گرمی(۱۶) رد.
2026-07-28 18:55:59 +03:30
*/
2026-07-28 19:08:04 +03:30
private function findZarinKarInListKarSakhte(string $productName, bool $exactOnly = false, bool $countDocOnly = true): ?array
2026-07-28 18:55:59 +03:30
{
$response = $this->zarinRequest([
'kind' => 'listKarSakhte',
], 30);
if (!$response || !$response->successful()) {
return null;
}
$rows = $this->zarinRowsFromBody($response->json());
if (empty($rows)) {
return null;
}
2026-07-28 19:08:04 +03:30
$allowedFk = $this->zarinCountFkNoes();
2026-07-28 18:55:59 +03:30
$exact = null;
$fuzzy = null;
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
2026-07-28 19:08:04 +03:30
$fkNoe = (string) ($row['fkNoe'] ?? $row['fknoe'] ?? '');
if ($countDocOnly && $fkNoe !== '' && !in_array($fkNoe, $allowedFk, true)) {
continue;
}
2026-07-28 18:55:59 +03:30
$name = $row['namFelezsakhthe']
?? $row['nam_kar_sakhte']
?? $row['nameKar']
?? $row['nam']
?? $row['name']
?? '';
$rowName = $this->normalizeZarinName($name);
$idKar = $this->extractUsableZarinKarId($row);
if (!$idKar || $rowName === '') {
continue;
}
if ($rowName === $productName) {
$exact = [
'idKar' => $idKar,
'row' => $row,
'match' => 'exact',
'source' => 'listKarSakhte',
2026-07-28 19:08:04 +03:30
'fkNoe' => $fkNoe,
2026-07-28 18:55:59 +03:30
];
break;
}
if (
!$exactOnly
&& (
$this->containsText($rowName, $productName)
|| $this->containsText($productName, $rowName)
)
) {
$fuzzy ??= [
'idKar' => $idKar,
'row' => $row,
'match' => 'fuzzy',
'source' => 'listKarSakhte',
2026-07-28 19:08:04 +03:30
'fkNoe' => $fkNoe,
2026-07-28 18:55:59 +03:30
];
}
}
return $exact ?? ($exactOnly ? null : $fuzzy);
}
2026-07-28 18:40:06 +03:30
private function matchZarinKarRow(string $productName, array $row, bool $exactOnly = false): ?array
2026-07-28 14:02:23 +03:30
{
$name = $row['nameKar']
?? $row['nam_kar_sakhte']
?? $row['nam']
?? $row['name']
?? $row['namKar']
?? $row['title']
?? '';
$rowName = $this->normalizeZarinName($name);
$idKar = $this->extractUsableZarinKarId($row);
2026-07-28 18:40:06 +03:30
if (!$idKar || $rowName === '') {
return null;
}
if ($rowName === $productName) {
return [
'idKar' => $idKar,
'row' => $row,
'match' => 'exact',
];
}
if ($exactOnly) {
2026-07-28 14:02:23 +03:30
return null;
}
if (
$this->containsText($rowName, $productName) ||
$this->containsText($productName, $rowName)
) {
return [
'idKar' => $idKar,
'row' => $row,
2026-07-28 18:40:06 +03:30
'match' => 'fuzzy',
2026-07-28 14:02:23 +03:30
];
}
return null;
}
2026-07-28 18:40:06 +03:30
private function findZarinKarInInfokarbarcodi(string $productName, $goroheKar, bool $exactOnly = false): ?array
2026-07-28 14:02:23 +03:30
{
2026-07-28 18:40:06 +03:30
$exact = null;
$fuzzy = null;
2026-07-23 20:24:58 +03:30
for ($page = 1; $page <= 10; $page++) {
2026-07-28 14:02:23 +03:30
$response = $this->zarinRequest([
2026-07-23 20:24:58 +03:30
'kind' => 'infokarbarcodi',
'id' => $goroheKar,
'vazneat' => 0,
'pageNum' => $page,
'detailCount' => 250,
2026-07-28 14:02:23 +03:30
]);
2026-07-23 20:24:58 +03:30
2026-07-28 14:02:23 +03:30
// روی بعضی سرورهای زرین جدول barcodetala نیست → 400؛ برو سراغ infosakhte
2026-07-23 20:24:58 +03:30
if (!$response || !$response->successful()) {
return null;
}
2026-07-28 14:02:23 +03:30
$rows = $this->zarinRowsFromBody($response->json());
2026-07-23 20:24:58 +03:30
if (empty($rows)) {
break;
}
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
2026-07-28 18:40:06 +03:30
$hit = $this->matchZarinKarRow($productName, $row, $exactOnly);
if (!$hit) {
continue;
2026-07-23 20:24:58 +03:30
}
2026-07-28 18:40:06 +03:30
if (($hit['match'] ?? '') === 'exact') {
$exact = $hit;
break 2;
}
$fuzzy ??= $hit;
2026-07-23 20:24:58 +03:30
}
if (count($rows) < 250) {
break;
}
}
2026-07-28 18:40:06 +03:30
return $exact ?? ($exactOnly ? null : $fuzzy);
2026-07-23 20:24:58 +03:30
}
2026-07-28 14:02:23 +03:30
/** لیست کارهای ساخته — فیلد nam_kar_sakhte / id (تست‌شده روی API زرین) */
2026-07-28 18:40:06 +03:30
private function findZarinKarInInfosakhte(string $productName, $goroheKar, bool $exactOnly = false): ?array
2026-07-28 14:02:23 +03:30
{
$response = $this->zarinRequest([
'kind' => 'infosakhte',
], 30);
if (!$response || !$response->successful()) {
return null;
}
$rows = $this->zarinRowsFromBody($response->json());
if (empty($rows)) {
return null;
}
$noeKar = (string) $this->zarinDefaultNoeKar();
$gorohe = (string) $goroheKar;
2026-07-28 18:40:06 +03:30
$exactPreferred = null;
$exactFallback = null;
$fuzzyPreferred = null;
$fuzzyFallback = null;
2026-07-28 14:02:23 +03:30
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
2026-07-28 18:40:06 +03:30
$hit = $this->matchZarinKarRow($productName, $row, $exactOnly);
2026-07-28 14:02:23 +03:30
if (!$hit) {
continue;
}
2026-07-28 18:40:06 +03:30
$idGp = (string) ($row['idGpFelez'] ?? $row['idgpfelez'] ?? $row['gorooheKar'] ?? '');
$inGroup = $idGp !== '' && ($idGp === $gorohe || $idGp === $noeKar);
$isExact = ($hit['match'] ?? '') === 'exact';
if ($isExact && $inGroup) {
$exactPreferred = $hit;
2026-07-28 14:02:23 +03:30
break;
}
2026-07-28 18:40:06 +03:30
if ($isExact) {
$exactFallback ??= $hit;
} elseif ($inGroup) {
$fuzzyPreferred ??= $hit;
} else {
$fuzzyFallback ??= $hit;
2026-07-28 14:02:23 +03:30
}
}
2026-07-28 18:40:06 +03:30
if ($exactOnly) {
return $exactPreferred ?? $exactFallback;
}
return $exactPreferred ?? $exactFallback ?? $fuzzyPreferred ?? $fuzzyFallback;
2026-07-28 14:02:23 +03:30
}
2026-07-23 20:24:58 +03:30
private function getZarinKarById($idKar)
{
if (empty($idKar) || !is_numeric($idKar)) {
return null;
}
$response = $this->zarinRequest([
'kind' => 'infosakhte',
'id' => (int) $idKar,
]);
if (!$response || !$response->successful()) {
return null;
}
$body = $response->json();
$rows = $this->zarinRowsFromBody($body);
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
2026-07-28 18:40:06 +03:30
// infosakhte: کد کار = id (نه pkFelez)
$rowId = $row['idKar']
2026-07-23 20:24:58 +03:30
?? $row['idkar']
2026-07-28 18:40:06 +03:30
?? $row['IDKar']
?? $row['id']
2026-07-23 20:24:58 +03:30
?? null;
if ((string) $rowId === (string) $idKar) {
return $row;
}
}
return null;
}
2026-07-28 18:40:06 +03:30
/**
* کد کار قابل استفاده برای sabtSanadTedadi.
* infosakhte → فیلد id؛ addFelezsakhteModel → idKar.
* pkFelez هرگز idKar نیست.
*/
2026-07-23 20:24:58 +03:30
private function extractUsableZarinKarId(array $row)
{
foreach ([
'idKar',
'idkar',
'IDKar',
'id',
2026-07-28 18:40:06 +03:30
'zarrinId',
'zarinId',
2026-07-23 20:24:58 +03:30
'code',
'Code',
] as $key) {
2026-07-28 18:40:06 +03:30
if (isset($row[$key]) && is_numeric($row[$key]) && (int) $row[$key] > 0) {
2026-07-23 20:24:58 +03:30
return (int) $row[$key];
}
}
return null;
}
2026-07-28 18:40:06 +03:30
/** پاسخ addFelezsakhteModel: {"state":true,"msg":"...","idKar":3130} */
private function extractIdKarFromCreateResponse($body): ?int
{
if (!is_array($body)) {
return null;
}
foreach (['idKar', 'idkar', 'IDKar'] as $key) {
if (isset($body[$key]) && is_numeric($body[$key]) && (int) $body[$key] > 0) {
return (int) $body[$key];
}
}
$data = $body['data'] ?? null;
if (is_array($data)) {
if (isset($data[0]) && is_array($data[0])) {
$fromRow = $this->extractUsableZarinKarId($data[0]);
if ($fromRow) {
return $fromRow;
}
}
$fromData = $this->extractUsableZarinKarId($data);
if ($fromData) {
return $fromData;
}
}
return null;
}
2026-07-23 20:24:58 +03:30
private function createZarinKarForProduct(Product $product)
{
$params = [
'kind' => 'addFelezsakhteModel',
'nameKar' => $product->title,
2026-07-28 19:08:04 +03:30
'noeKar' => $this->zarinCountNoeKar(),
2026-07-23 20:24:58 +03:30
'gorooheKar' => $this->zarinDefaultGoroheKar(),
];
$response = $this->zarinRequest($params);
if (!$response) {
return [
'ok' => false,
'message' => 'تنظیمات زرین کامل نیست. zarin_api یا zarin_token تنظیم نشده است.',
'status_code' => 422,
'sent_params' => $params,
];
}
if (!$response->successful()) {
return [
'ok' => false,
'message' => 'ساخت کد کار در زرین ناموفق بود.',
'status_code' => 502,
'status' => $response->status(),
'body' => $response->body(),
'json' => $response->json(),
'sent_params' => $params,
];
}
$body = $response->json();
2026-07-28 18:55:59 +03:30
if (is_array($body) && array_key_exists('state', $body) && filter_var($body['state'], FILTER_VALIDATE_BOOLEAN) === false) {
// ممکن است کالا از قبل باشد و idKar در پیام آمده باشد
$createdIdKar = $this->extractIdKarFromCreateResponse($body);
if ($createdIdKar) {
$product->finance_p_name = (string) $createdIdKar;
$product->save();
return [
'ok' => true,
'source' => 'create_response_idKar_despite_state',
'idKar' => $createdIdKar,
'product' => $product->fresh(),
'zarin_response' => $body,
'sent_params_success' => $params,
];
}
return [
'ok' => false,
'message' => (string) ($body['detail'] ?? $body['msg'] ?? $body['message'] ?? 'ساخت کد کار در زرین ناموفق بود.'),
'status_code' => 422,
'zarin_response' => $body,
'sent_params' => $params,
];
}
2026-07-28 18:40:06 +03:30
$createdIdKar = $this->extractIdKarFromCreateResponse(is_array($body) ? $body : null);
2026-07-23 20:24:58 +03:30
2026-07-28 18:40:06 +03:30
// اولویت با idKar برگشتی از ساخت — جستجوی فازی نام می‌تواند کالای اشتباه بچسباند
if ($createdIdKar) {
$product->finance_p_name = (string) $createdIdKar;
$product->save();
return [
'ok' => true,
'source' => 'created_idKar',
'idKar' => $createdIdKar,
'product' => $product->fresh(),
'zarin_row' => $this->getZarinKarById($createdIdKar),
'zarin_response' => $body,
'sent_params_success' => $params,
];
}
2026-07-23 20:24:58 +03:30
$foundAfterCreate = $this->findZarinKarForProduct($product, $this->zarinDefaultGoroheKar());
if ($foundAfterCreate && !empty($foundAfterCreate['idKar']) && is_numeric($foundAfterCreate['idKar'])) {
$product->finance_p_name = (string) $foundAfterCreate['idKar'];
$product->save();
return [
'ok' => true,
'source' => 'created_and_found_by_name',
'idKar' => (int) $foundAfterCreate['idKar'],
'product' => $product->fresh(),
'zarin_row' => $foundAfterCreate['row'],
'zarin_response' => $body,
'sent_params_success' => $params,
];
}
return [
'ok' => false,
2026-07-28 18:40:06 +03:30
'message' => 'زرین کالا را ساخت، اما idKar قابل استفاده برنگشت.',
2026-07-23 20:24:58 +03:30
'status_code' => 422,
'zarin_response' => $body,
'sent_params' => $params,
];
}
private function ensureZarinKarForProduct(Product $product, $forceCreate = false)
{
$currentIdKar = $product->finance_p_name;
2026-07-28 18:40:06 +03:30
$productName = $this->normalizeZarinName($product->title);
2026-07-23 20:24:58 +03:30
2026-07-28 18:55:59 +03:30
// بعد از «کد کار اشتباه»: کالای تازه بساز؛ اگر ساخت نشد، exact از کاتالوگ
if ($forceCreate) {
$this->clearProductZarinKar($product);
$product->refresh();
$created = $this->createZarinKarForProduct($product);
if (!empty($created['ok']) && !empty($created['idKar'])) {
return $created;
}
$found = $this->findZarinKarForProduct($product, $this->zarinDefaultGoroheKar(), true);
if ($found && !empty($found['idKar']) && is_numeric($found['idKar'])) {
$product->finance_p_name = (string) $found['idKar'];
$product->save();
return [
'ok' => true,
'source' => 'found_exact_after_create_failed',
'idKar' => (int) $found['idKar'],
'product' => $product->fresh(),
'zarin_row' => $found['row'],
'create_error' => $created,
];
}
return $created;
}
if (!empty($currentIdKar) && is_numeric($currentIdKar)) {
2026-07-23 20:24:58 +03:30
$existingRow = $this->getZarinKarById($currentIdKar);
if ($existingRow) {
2026-07-28 18:40:06 +03:30
$rowName = $this->normalizeZarinName(
$existingRow['nameKar']
?? $existingRow['nam_kar_sakhte']
?? $existingRow['nam']
?? $existingRow['name']
?? $existingRow['namKar']
?? $existingRow['title']
?? ''
);
2026-07-28 19:08:04 +03:30
$idGp = (string) ($existingRow['idGp'] ?? $existingRow['idgp'] ?? $existingRow['fkNoe'] ?? '');
$wrongType = $idGp !== '' && !in_array($idGp, $this->zarinCountFkNoes(), true);
// اگر idKar موجود مال کالای دیگری است یا نوع گرمی است (نه تعدادی) قبول نکن
if (
$wrongType
|| ($rowName !== '' && $productName !== '' && $rowName !== $productName)
) {
2026-07-28 18:40:06 +03:30
$this->clearProductZarinKar($product);
$product->refresh();
} else {
$usableIdKar = $this->extractUsableZarinKarId($existingRow) ?: (int) $currentIdKar;
2026-07-23 20:24:58 +03:30
2026-07-28 18:40:06 +03:30
if ((string) $product->finance_p_name !== (string) $usableIdKar) {
$product->finance_p_name = (string) $usableIdKar;
$product->save();
}
return [
'ok' => true,
'source' => 'existing_verified',
'idKar' => (int) $usableIdKar,
'product' => $product->fresh(),
'zarin_row' => $existingRow,
];
2026-07-23 20:24:58 +03:30
}
2026-07-28 18:40:06 +03:30
} else {
$this->clearProductZarinKar($product);
$product->refresh();
2026-07-23 20:24:58 +03:30
}
}
2026-07-28 18:55:59 +03:30
// اول فقط exact؛ اگر نبود fuzzy از listKarSakhte/infosakhte
$found = $this->findZarinKarForProduct($product, $this->zarinDefaultGoroheKar(), true)
?? $this->findZarinKarForProduct($product, $this->zarinDefaultGoroheKar(), false);
2026-07-23 20:24:58 +03:30
2026-07-28 18:40:06 +03:30
if ($found && !empty($found['idKar']) && is_numeric($found['idKar'])) {
$product->finance_p_name = (string) $found['idKar'];
$product->save();
2026-07-23 20:24:58 +03:30
2026-07-28 18:40:06 +03:30
return [
'ok' => true,
2026-07-28 18:55:59 +03:30
'source' => 'found_by_name',
2026-07-28 18:40:06 +03:30
'idKar' => (int) $found['idKar'],
'product' => $product->fresh(),
'zarin_row' => $found['row'],
'match' => $found['match'] ?? null,
2026-07-28 18:55:59 +03:30
'list_source' => $found['source'] ?? null,
2026-07-28 18:40:06 +03:30
];
2026-07-23 20:24:58 +03:30
}
return $this->createZarinKarForProduct($product);
}
2026-07-28 18:40:06 +03:30
private function clearProductZarinKar(Product $product): void
{
if ($product->finance_p_name === null || $product->finance_p_name === '') {
return;
}
$product->finance_p_name = null;
$product->save();
}
private function isInvalidZarinKarError($response, ?string $httpError = null): bool
{
$haystack = '';
if (is_string($httpError)) {
$haystack .= $httpError;
}
if (is_array($response)) {
$haystack .= ' ' . json_encode($response, JSON_UNESCAPED_UNICODE);
} elseif (is_string($response)) {
$haystack .= ' ' . $response;
}
return str_contains($haystack, 'کد کار اشتباه');
}
2026-07-23 20:24:58 +03:30
public function table(Request $request)
{
2026-08-27 16:16:20 +03:30
$view = (string) $request->input('view', 'all');
2026-07-23 20:24:58 +03:30
$query = Zarin::with('customer')->orderByDesc('id');
2026-08-27 16:16:20 +03:30
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');
});
}
2026-07-23 20:24:58 +03:30
\App\DB\Te\TableEngine::create($query, $request)
->initSearch(['desc', 'type'])
->select(['*'])
->export($query);
return response()->json($query);
}
public function show($id)
{
$zarin = Zarin::with('customer')->findOrFail($id);
return response()->json($zarin);
}
2026-07-28 14:02:23 +03:30
/**
* لیست حساب‌های زرین — فقط listhesabha (Get100Account لیست نیست و ۴۰۰ می‌دهد).
*
* @return array{rows: array<int, array>, error: ?string, http_status: ?int}
*/
2026-07-23 20:24:58 +03:30
private function fetchAllZarinCustomers(): array
{
2026-07-28 14:02:23 +03:30
if (!$this->zarinAccountingUrl() || !$this->zarinToken()) {
return [
'rows' => [],
'error' => 'تنظیمات zarin_api یا zarin_token خالی است.',
'http_status' => null,
];
}
2026-07-23 20:24:58 +03:30
try {
2026-07-28 14:02:23 +03:30
// listhesabha کامل است (~۳۰۰ ردیف / چند ثانیه)؛ timeout بالاتر از درخواست‌های کوتاه
2026-07-23 20:24:58 +03:30
$response = $this->zarinRequest([
'kind' => 'listhesabha',
2026-07-28 14:02:23 +03:30
], 45);
2026-07-23 20:24:58 +03:30
2026-07-28 14:02:23 +03:30
if (!$response) {
return [
'rows' => [],
'error' => 'درخواست به زرین ساخته نشد (آدرس/توکن).',
'http_status' => null,
];
2026-07-23 20:24:58 +03:30
}
2026-07-28 14:02:23 +03:30
$status = $response->status();
$body = $response->body();
if (!$response->successful()) {
$snippet = mb_substr(trim((string) $body), 0, 180);
$msg = "listhesabha ناموفق (HTTP {$status})";
if ($snippet !== '') {
$msg .= ': ' . $snippet;
}
return [
'rows' => [],
'error' => $msg,
'http_status' => $status,
];
}
$json = $response->json();
if (!is_array($json)) {
return [
'rows' => [],
'error' => 'پاسخ listhesabha JSON معتبر نیست. طول بدنه: ' . strlen((string) $body),
'http_status' => $status,
];
}
$rows = $this->zarinRowsFromBody($json);
if (empty($rows)) {
return [
'rows' => [],
'error' => 'listhesabha خالی بود یا کلید data پیدا نشد. کلیدها: '
. implode(',', array_keys($json)),
'http_status' => $status,
];
}
return [
'rows' => $rows,
'error' => null,
'http_status' => $status,
];
2026-07-23 20:24:58 +03:30
} catch (\Exception $e) {
2026-07-28 14:02:23 +03:30
Log::warning('zarin listhesabha failed', ['message' => $e->getMessage()]);
return [
'rows' => [],
'error' => 'خطا در ارتباط با زرین: ' . $e->getMessage(),
'http_status' => null,
];
2026-07-23 20:24:58 +03:30
}
}
2026-07-28 14:02:23 +03:30
/** نام حساب از پاسخ listhesabha (فیلد nam، نه name) */
private function extractZarinName(array $item): string
{
$nam = trim((string) ($item['nam'] ?? $item['Nam'] ?? ''));
if ($nam !== '') {
// "تستی * هوم" → "تستی هوم"
$nam = preg_replace('/\s*\*\s*/u', ' ', $nam) ?? $nam;
return trim(preg_replace('/\s+/u', ' ', $nam) ?? $nam);
}
$name = trim((string) ($item['name'] ?? $item['Name'] ?? ''));
$family = trim((string) ($item['family'] ?? $item['Family'] ?? ''));
$full = trim($name . ' ' . $family);
return $full !== '' ? $full : $name;
}
private function extractZarinGroup(array $item): string
{
return trim((string) (
$item['gorooh']
?? $item['GoroupName']
?? $item['group']
?? $item['Group']
?? ''
));
}
2026-07-23 20:24:58 +03:30
public function customerSyncStatus(Request $request): \Illuminate\Http\JsonResponse
2026-07-28 14:57:28 +03:30
{
return $this->buildCustomerSyncStatus($request, $this->requestWantsZarinList($request));
}
/** مسیر بدون query — وقتی nginx QUERY_STRING را نمی‌فرستد */
public function customerSyncStatusWithZarin(Request $request): \Illuminate\Http\JsonResponse
{
return $this->buildCustomerSyncStatus($request, true);
}
private function buildCustomerSyncStatus(Request $request, bool $include): \Illuminate\Http\JsonResponse
2026-07-23 20:24:58 +03:30
{
$panelTotal = Customer::whereNull('deleted_at')->count();
$panelRegistered = Customer::whereNull('deleted_at')->whereNotNull('moshtari_code')->count();
$panelOnly = $panelTotal - $panelRegistered;
$panelOnlyQuery = Customer::whereNull('deleted_at')->whereNull('moshtari_code')
->select('id', 'name', 'mobile', 'created_at');
if ($search = $request->input('search')) {
$panelOnlyQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('mobile', 'like', "%{$search}%");
});
}
$panelOnlyList = $panelOnlyQuery->orderBy('id')->paginate(20);
$zarinTotal = null;
$zarinOnlyCount = null;
2026-07-28 14:02:23 +03:30
$zarinFetchError = null;
$zarinHttpStatus = null;
2026-07-28 14:49:55 +03:30
$zarinGroupFilter = $this->zarinCustomerGroupName();
2026-07-23 20:24:58 +03:30
$zarinOnlyResult = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1];
$debugSample = [];
if ($include) {
2026-07-28 14:02:23 +03:30
$fetched = $this->fetchAllZarinCustomers();
$zarinCustomers = $fetched['rows'];
$zarinFetchError = $fetched['error'];
$zarinHttpStatus = $fetched['http_status'];
2026-07-28 14:49:55 +03:30
// فقط گروه تنظیم‌شده (گروه ۶ / zarin_name_g) — نه همهٔ حساب‌های زرین
if ($zarinGroupFilter !== '' && !empty($zarinCustomers)) {
$zarinCustomers = array_values(array_filter($zarinCustomers, function ($item) use ($zarinGroupFilter) {
if (!is_array($item)) {
return false;
}
$g = $this->extractZarinGroup($item);
return $g !== '' && $this->zarinGroupNamesMatch($g, $zarinGroupFilter);
}));
}
2026-07-23 20:24:58 +03:30
$zarinTotal = count($zarinCustomers);
$maps = $this->panelAccountingMaps();
$walletService = app(ZarinInboundWalletService::class);
2026-07-23 20:24:58 +03:30
$zarinOnlyList = [];
$phoneMatches = [];
$phoneConflicts = [];
$withPhone = 0;
$withoutPhone = 0;
2026-07-23 20:24:58 +03:30
foreach ($zarinCustomers as $item) {
if (!is_array($item)) {
continue;
}
if (count($debugSample) < 3) {
$debugSample[] = $item;
}
$code = $this->extractZarinHesabId($item) ?? ($item['idHesab'] ?? $item['Code'] ?? $item['code'] ?? $item['id'] ?? null);
$codeKey = $this->accountingCodeKey($code);
2026-07-23 20:24:58 +03:30
$phone = $this->extractZarinPhone($item);
$hasPhone = Customer::normalizeMobile($phone) !== null;
if ($hasPhone) {
$withPhone++;
} else {
$withoutPhone++;
}
if ($codeKey !== null && isset($maps['by_code'][$codeKey])) {
2026-07-23 20:24:58 +03:30
continue;
}
$mobileKey = Customer::normalizeMobile($phone);
if ($mobileKey && isset($maps['by_mobile'][$mobileKey])) {
$panel = $maps['by_mobile'][$mobileKey];
$panelCodeKey = $this->accountingCodeKey($panel->moshtari_code);
$row = [
'code' => $codeKey !== null ? (int) $codeKey : null,
'name' => $this->extractZarinName($item),
'tel' => $phone,
'panel_id' => (int) $panel->id,
'panel_name' => $panel->name,
'panel_code' => $panelCodeKey,
];
if ($panelCodeKey && $panelCodeKey !== $codeKey) {
$phoneConflicts[] = $row;
} else {
$phoneMatches[] = $row;
}
continue;
}
if ($codeKey === null) {
continue;
}
$mande = $walletService->mandeFromAccountingRow($item);
2026-07-23 20:24:58 +03:30
$zarinOnlyList[] = [
'code' => (int) $codeKey,
2026-07-28 14:02:23 +03:30
'name' => $this->extractZarinName($item),
2026-07-23 20:24:58 +03:30
'tel' => $phone,
'gid' => $item['GID'] ?? null,
2026-07-28 14:02:23 +03:30
'group' => $this->extractZarinGroup($item),
'has_phone' => $hasPhone,
'gold' => $mande['gold'] ?? null,
'cash' => $mande['cash'] ?? null,
2026-07-23 20:24:58 +03:30
];
}
$zarinOnlyCount = count($zarinOnlyList);
$zarinOnlyResult = [
'data' => array_values($zarinOnlyList),
'total' => $zarinOnlyCount,
'current_page' => 1,
'last_page' => 1,
];
}
$payload = [
'panel_total' => $panelTotal,
'panel_registered' => $panelRegistered,
'panel_only' => $panelOnly,
'zarin_total' => $zarinTotal,
'zarin_only_count' => $zarinOnlyCount,
'zarin_with_phone_count' => $include ? ($withPhone ?? 0) : null,
'zarin_without_phone_count' => $include ? ($withoutPhone ?? 0) : null,
'zarin_importable_count' => $include
? count(array_filter($zarinOnlyList ?? [], fn ($r) => ! empty($r['has_phone'])))
: null,
'phone_match_count' => $include ? count($phoneMatches ?? []) : null,
'phone_conflict_count' => $include ? count($phoneConflicts ?? []) : null,
'phone_matches' => $phoneMatches ?? [],
'phone_conflicts' => $phoneConflicts ?? [],
2026-07-28 14:02:23 +03:30
'zarin_fetch_error' => $zarinFetchError,
'zarin_http_status' => $zarinHttpStatus,
2026-07-28 14:49:55 +03:30
'zarin_group_filter' => $zarinGroupFilter,
'include_zarin' => $include,
'include_zarin_param' => $request->query('include_zarin', $request->input('include_zarin')),
2026-07-23 20:24:58 +03:30
'panel_only_list' => $panelOnlyList,
'zarin_only_list' => $zarinOnlyResult,
];
2026-07-28 14:57:28 +03:30
if ($include && ($request->boolean('raw') || $request->input('raw'))) {
2026-07-23 20:24:58 +03:30
$payload['zarin_debug_sample'] = $debugSample;
}
2026-07-28 14:49:55 +03:30
return response()->json($payload)->header('Cache-Control', 'no-store, private');
2026-07-23 20:24:58 +03:30
}
private function extractZarinPhone(array $item): string
{
2026-07-28 14:02:23 +03:30
$candidates = ['mobile', 'Mobile', 'tell', 'Tell', 'tel', 'Tel', 'Phone', 'CellPhone', 'Cell', 'MobileNo', 'Telephone', 'Mobile1'];
2026-07-23 20:24:58 +03:30
foreach ($candidates as $key) {
if (isset($item[$key]) && trim((string) $item[$key]) !== '') {
return trim((string) $item[$key]);
}
}
foreach ($item as $k => $v) {
if ($v === null || trim((string) $v) === '') {
continue;
}
$lk = strtolower((string) $k);
if (str_contains($lk, 'mobile') || str_contains($lk, 'tel')
|| str_contains($lk, 'phone') || str_contains($lk, 'cell')) {
return trim((string) $v);
}
}
return '';
}
/** کد حساب زرین از پاسخ addhesab / listhesabha — OK و Code هم مثل idHesab معتبرند */
private function extractZarinHesabId(array $row): ?int
{
foreach (['idHesab', 'idhesab', 'Code', 'code', 'OK', 'id'] as $key) {
if (! array_key_exists($key, $row)) {
continue;
}
$value = $row[$key];
if ($value === null || $value === '' || $value === false || $value === true) {
continue;
}
if (is_numeric($value) && (int) $value > 0) {
return (int) $value;
}
}
return null;
}
private function accountingCodeKey(mixed $code): ?string
{
if ($code === null || $code === '' || $code === false) {
return null;
}
$s = trim((string) $code);
if ($s === '' || $s === '0' || $s === '-') {
return null;
}
if (is_numeric($s)) {
$n = (int) $s;
return $n > 0 ? (string) $n : null;
}
return $s;
}
/**
* @return array{by_code: array<string, Customer>, by_mobile: array<string, Customer>}
*/
private function panelAccountingMaps(): array
{
$byCode = [];
$byMobile = [];
$rows = Customer::whereNull('deleted_at')->get(['id', 'name', 'mobile', 'moshtari_code', 'type']);
foreach ($rows as $row) {
$codeKey = $this->accountingCodeKey($row->moshtari_code);
if ($codeKey !== null) {
$byCode[$codeKey] = $row;
}
$mobile = Customer::normalizeMobile((string) ($row->mobile ?? ''));
if ($mobile !== null) {
$byMobile[$mobile] = $row;
}
}
return ['by_code' => $byCode, 'by_mobile' => $byMobile];
}
/** حساب از قبل ساخته‌شده در زرین — با موبایل، تا addhesab دوم نسازیم */
private function findExistingZarinHesabId(?string $mobile): ?int
{
$want = Customer::normalizeMobile($mobile ?? '');
if ($want === null) {
return null;
}
$fetched = $this->fetchAllZarinCustomers();
$rows = $fetched['rows'] ?? [];
if ($rows === []) {
return null;
}
$groupFilter = $this->zarinCustomerGroupName();
$sameGroup = null;
$any = null;
foreach ($rows as $item) {
if (! is_array($item)) {
continue;
}
$phone = $this->extractZarinPhone($item);
$got = Customer::normalizeMobile($phone);
if ($got === null || $got !== $want) {
continue;
}
$id = $this->extractZarinHesabId($item);
if ($id === null) {
continue;
}
$g = $this->extractZarinGroup($item);
if ($groupFilter !== '' && $g !== '' && $this->zarinGroupNamesMatch($g, $groupFilter)) {
$sameGroup = $id;
break;
}
$any ??= $id;
}
return $sameGroup ?? $any;
}
private function bindCustomerZarinHesab(Customer $customer, int $idHesab): int
{
$customer->moshtari_code = $idHesab;
$customer->save();
return $idHesab;
}
2026-07-23 20:24:58 +03:30
public function syncPanelToZarin(): \Illuminate\Http\JsonResponse
{
$customers = Customer::whereNull('deleted_at')
->whereNull('moshtari_code')
->select('id', 'name', 'mobile')
->get();
$success = 0;
$failed = 0;
$errors = [];
foreach ($customers as $customer) {
$result = $this->sync()->registerCustomer($customer);
if ($result['ok'] ?? false) {
$success++;
} else {
$failed++;
$errors[] = ['name' => $customer->name, 'mobile' => $customer->mobile, 'error' => $result['message'] ?? ''];
}
}
return response()->json([
'ok' => true,
'success' => $success,
'failed' => $failed,
'errors' => $errors,
]);
}
public function importFromZarin(Request $request): \Illuminate\Http\JsonResponse
{
$validated = $request->validate([
'customers' => 'required|array',
'customers.*.code' => 'required|integer',
2026-07-28 14:28:28 +03:30
'customers.*.name' => 'nullable|string',
2026-07-23 20:24:58 +03:30
'customers.*.tel' => 'nullable|string',
]);
$success = 0;
$failed = 0;
$errors = [];
/** @var list<Customer> $importedForWallet */
$importedForWallet = [];
2026-07-23 20:24:58 +03:30
foreach ($validated['customers'] as $item) {
$mobile = Customer::normalizeMobile($item['tel'] ?? '') ?? '';
$fullName = trim($item['name']);
$code = (int) $item['code'];
2026-07-28 14:28:28 +03:30
if ($fullName === '') {
$fullName = 'مشتری زرین ' . $code;
2026-07-23 20:24:58 +03:30
}
if ($mobile === '') {
$failed++;
$errors[] = [
'name' => $item['name'],
'code' => $item['code'],
'error' => 'شماره موبایل خالی است — وارد نشد',
];
continue;
}
2026-07-23 20:24:58 +03:30
$existing = Customer::whereNull('deleted_at')->where('moshtari_code', $code)->first();
if ($existing) {
$changed = false;
if (($existing->name === null || $existing->name === '') && $fullName !== '') {
$existing->fname = $fullName;
$existing->lname = (string) ($existing->lname ?? '');
$existing->name = $fullName;
$changed = true;
}
if ($mobile !== '' && ($existing->mobile === null || $existing->mobile === '')) {
if (Customer::isMobileTaken($mobile, (int) $existing->id)) {
$failed++;
$errors[] = [
'name' => $item['name'],
'code' => $item['code'],
'error' => 'شماره موبایل تکراری است',
];
continue;
}
$existing->mobile = $mobile;
if (empty($existing->password)) {
$existing->password = $mobile;
}
$changed = true;
}
if ($changed) {
$existing->save();
}
$importedForWallet[] = $existing;
2026-07-23 20:24:58 +03:30
$success++;
continue;
}
if ($mobile !== '') {
$byMobile = Customer::findActiveByMobile($mobile);
if ($byMobile) {
if (empty($byMobile->moshtari_code)) {
$byMobile->moshtari_code = $code;
if ($fullName !== '') {
$byMobile->fname = $fullName;
$byMobile->lname = '';
$byMobile->name = $fullName;
}
if (empty($byMobile->password)) {
$byMobile->password = $mobile;
}
$byMobile->save();
$importedForWallet[] = $byMobile;
2026-07-23 20:24:58 +03:30
$success++;
continue;
}
$failed++;
$errors[] = [
'name' => $item['name'],
'code' => $item['code'],
'error' => 'شماره موبایل تکراری است',
];
continue;
}
}
$characters = '0123456789abcdefghijklmnopqrstuvwxyz';
do {
$referralId = '';
for ($i = 0; $i < 4; $i++) {
$referralId .= $characters[random_int(0, strlen($characters) - 1)];
}
} while (Customer::where('referral_id', $referralId)->exists());
try {
$customer = new Customer();
$customer->fname = $fullName;
$customer->lname = '';
$customer->name = $fullName;
$customer->mobile = $mobile;
2026-07-23 20:24:58 +03:30
$customer->active = true;
$customer->moshtari_code = $item['code'];
$customer->referral_id = $referralId;
$customer->type = 2;
$customer->password = $mobile;
2026-07-23 20:24:58 +03:30
$customer->save();
$importedForWallet[] = $customer;
2026-07-23 20:24:58 +03:30
$success++;
} catch (\Throwable $e) {
$failed++;
$msg = $e->getMessage();
if ($e instanceof UnprocessableEntityHttpException) {
$msg = $e->getMessage();
} elseif ($e instanceof \Illuminate\Database\QueryException && (int) ($e->errorInfo[1] ?? 0) === 1062) {
$msg = 'شماره موبایل تکراری است';
}
$errors[] = ['name' => $item['name'], 'code' => $item['code'], 'error' => $msg];
}
}
if ($importedForWallet !== []) {
try {
app(ZarinInboundWalletService::class)->syncAfterImport($importedForWallet);
} catch (\Throwable $e) {
Log::warning('Zarin import wallet sync batch failed', [
'count' => count($importedForWallet),
'error' => $e->getMessage(),
]);
}
}
2026-07-23 20:24:58 +03:30
return response()->json([
'ok' => true,
'success' => $success,
'failed' => $failed,
'errors' => $errors,
]);
}
public function linkMatchingPhones(): \Illuminate\Http\JsonResponse
{
$fetched = $this->fetchAllZarinCustomers();
$rows = $fetched['rows'] ?? [];
$groupFilter = $this->zarinCustomerGroupName();
if ($groupFilter !== '' && $rows !== []) {
$rows = array_values(array_filter($rows, function ($item) use ($groupFilter) {
if (! is_array($item)) {
return false;
}
$g = $this->extractZarinGroup($item);
return $g !== '' && $this->zarinGroupNamesMatch($g, $groupFilter);
}));
}
$maps = $this->panelAccountingMaps();
$linked = [];
$conflicts = [];
$toSync = [];
foreach ($rows as $item) {
if (! is_array($item)) {
continue;
}
$code = $this->extractZarinHesabId($item);
$codeKey = $this->accountingCodeKey($code);
if ($codeKey === null) {
continue;
}
$phone = $this->extractZarinPhone($item);
$mobileKey = Customer::normalizeMobile($phone);
if ($mobileKey === null || ! isset($maps['by_mobile'][$mobileKey])) {
continue;
}
$panel = $maps['by_mobile'][$mobileKey];
$panelCodeKey = $this->accountingCodeKey($panel->moshtari_code);
if (isset($maps['by_code'][$codeKey]) && (int) $maps['by_code'][$codeKey]->id !== (int) $panel->id) {
$conflicts[] = [
'code' => (int) $codeKey,
'name' => $this->extractZarinName($item),
'tel' => $phone,
'panel_id' => (int) $panel->id,
'panel_name' => $panel->name,
'error' => 'این کد زرین روی مشتری دیگری در پنل است',
];
continue;
}
if ($panelCodeKey === $codeKey) {
continue;
}
if ($panelCodeKey) {
$conflicts[] = [
'code' => (int) $codeKey,
'name' => $this->extractZarinName($item),
'tel' => $phone,
'panel_id' => (int) $panel->id,
'panel_name' => $panel->name,
'panel_code' => $panelCodeKey,
'error' => 'مشتری پنل کد دیگری دارد — دستی تصمیم بگیرید',
];
continue;
}
$panel->moshtari_code = (int) $codeKey;
$panel->save();
$maps['by_code'][$codeKey] = $panel;
$toSync[] = $panel;
$linked[] = [
'code' => (int) $codeKey,
'name' => $panel->name,
'tel' => $phone,
'panel_id' => (int) $panel->id,
];
}
if ($toSync !== []) {
try {
app(ZarinInboundWalletService::class)->syncAfterImport($toSync);
} catch (\Throwable $e) {
Log::warning('Zarin link-by-phone wallet sync failed', ['error' => $e->getMessage()]);
}
}
return response()->json([
'ok' => true,
'linked' => count($linked),
'conflicts' => count($conflicts),
'linked_rows' => $linked,
'conflict_rows' => $conflicts,
'message' => count($linked)
? (count($linked).' کد روی مشتری پنل با شماره یکسان نشست')
: (count($conflicts) ? 'کد یکسانی برای لینک خودکار نبود' : 'شماره یکسانی برای لینک پیدا نشد'),
]);
}
public function previewCustomerWallet(Request $request, $customerId): \Illuminate\Http\JsonResponse
{
if (! app(ConfigService::class)->isZarinAccounting()) {
return response()->json(['ok' => false, 'message' => 'حسابداری زرین فعال نیست'], 422);
}
$customer = Customer::findOrFail($customerId);
$code = (int) $request->input('code', $customer->moshtari_code);
$result = app(ZarinInboundWalletService::class)->previewForCustomer($customer, $code);
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'needs_decision' => (bool) ($result['needs_decision'] ?? false),
'message' => $result['message'] ?? '',
'result' => $result,
]);
}
public function syncCustomerWallet(Request $request, $customerId)
{
if (! app(ConfigService::class)->isZarinAccounting()) {
return response()->json(['ok' => false, 'message' => 'حسابداری زرین فعال نیست'], 422);
}
$customer = Customer::findOrFail($customerId);
if ((int) ($customer->moshtari_code ?? 0) <= 0) {
return response()->json(['ok' => false, 'message' => 'این مشتری کد زرین ندارد'], 422);
}
$overwrite = $request->boolean('overwrite');
$keep = $request->boolean('keep');
$alwaysAsk = $request->boolean('always_ask');
$askIfConflict = $request->boolean('ask_if_conflict', ! $overwrite && ! $keep && ! $alwaysAsk);
$result = app(ZarinInboundWalletService::class)->syncWalletFromZarin(
$customer,
null,
[
'overwrite' => $overwrite,
'keep' => $keep,
'ask_if_conflict' => $askIfConflict,
'always_ask' => $alwaysAsk,
]
);
$needsDecision = (bool) ($result['needs_decision'] ?? false);
$applied = (bool) ($result['applied'] ?? false);
$skipped = (bool) ($result['skipped'] ?? false);
$same = ! $applied && ! $needsDecision && ! $skipped
&& str_contains((string) ($result['message'] ?? ''), 'یکسان');
return response()->json([
'ok' => $applied || $skipped || $same,
'needs_decision' => $needsDecision,
'message' => $result['message'] ?? '',
'result' => $result,
]);
}
public function syncAllWallets(Request $request)
{
if (! app(ConfigService::class)->isZarinAccounting()) {
return response()->json(['ok' => false, 'message' => 'حسابداری زرین فعال نیست'], 422);
}
$limit = max(1, min(50, (int) $request->input('limit', 20)));
$offset = max(0, (int) $request->input('offset', 0));
$overwrite = $request->boolean('overwrite');
$base = Customer::query()
->whereNull('deleted_at')
->whereNotNull('moshtari_code')
->where('moshtari_code', '!=', '')
->where('moshtari_code', '!=', '0');
$total = (clone $base)->count();
$customers = (clone $base)
->orderBy('id')
->offset($offset)
->limit($limit)
->get(['id', 'name', 'mobile', 'moshtari_code', 'type']);
$walletService = app(ZarinInboundWalletService::class);
$codes = $customers->pluck('moshtari_code')->all();
$mandes = $walletService->fetchMandeByCodes($codes);
$success = 0;
$changed = 0;
$needsDecision = 0;
$failed = 0;
$errors = [];
$decisions = [];
foreach ($customers as $customer) {
try {
$code = (string) ((int) $customer->moshtari_code);
$result = $walletService->syncWalletFromZarin(
$customer,
$mandes[$code] ?? null,
[
'overwrite' => $overwrite,
'ask_if_conflict' => ! $overwrite,
]
);
if ($result['needs_decision'] ?? false) {
$needsDecision++;
$decisions[] = [
'id' => $customer->id,
'name' => $customer->name,
'mobile' => $customer->mobile,
'app_gold' => $result['app_gold'] ?? null,
'app_cash' => $result['app_cash'] ?? null,
'tahesab_gold' => $result['zarin_gold'] ?? $result['tahesab_gold'] ?? null,
'tahesab_cash' => $result['zarin_cash'] ?? $result['tahesab_cash'] ?? null,
'message' => $result['message'] ?? '',
];
continue;
}
$success++;
if ($result['applied'] ?? false) {
$changed++;
}
} catch (\Throwable $e) {
$failed++;
$errors[] = [
'id' => $customer->id,
'name' => $customer->name,
'mobile' => $customer->mobile,
'error' => $e->getMessage() !== '' ? $e->getMessage() : 'خطای ناشناخته',
];
}
}
$processed = $customers->count();
$nextOffset = $offset + $processed;
return response()->json([
'ok' => true,
'message' => $total === 0
? 'مشتری دارای کد زرین یافت نشد'
: "دستهٔ {$offset} تا {$nextOffset} از {$total}",
'total' => $total,
'offset' => $offset,
'processed' => $processed,
'next_offset' => $nextOffset,
'done' => $nextOffset >= $total,
'success' => $success,
'changed' => $changed,
'needs_decision' => $needsDecision,
'failed' => $failed,
'errors' => $errors,
'decisions' => $decisions,
]);
}
2026-07-23 20:24:58 +03:30
/**
* حذف مشتریانِ خرابِ واردشده از زرین (نام خالی) برای پاک‌سازی import ناقص.
* با dry_run=1 فقط تعداد و نمونه را نشان می‌دهد و چیزی حذف نمی‌کند.
*/
public function rollbackImport(Request $request): \Illuminate\Http\JsonResponse
{
$baseQuery = fn () => Customer::whereNotNull('moshtari_code')
->where(function ($q) {
$q->whereNull('name')->orWhere('name', '');
});
$count = $baseQuery()->count();
$sample = $baseQuery()
->select('id', 'fname', 'name', 'mobile', 'moshtari_code', 'created_at')
->orderByDesc('id')
->limit(20)
->get();
if ($request->boolean('dry_run')) {
return response()->json([
'dry_run' => true,
'count' => $count,
'sample' => $sample,
]);
}
$deleted = $baseQuery()->forceDelete();
return response()->json([
'ok' => true,
'deleted' => $deleted,
]);
}
public function retrySanad($id)
{
$zarin = Zarin::with('customer')->findOrFail($id);
$result = $this->sync()->retry($zarin);
2026-08-27 16:16:20 +03:30
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);
2026-07-23 20:24:58 +03:30
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
}
public function registerCustomer($customerId)
{
$customer = Customer::findOrFail($customerId);
$result = $this->sync()->registerCustomer($customer);
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
}
private function resolveProductId(array $data, ?Zarin $existing = null): ?int
{
if (!empty($data['product']['id'])) {
return (int) $data['product']['id'];
}
if (!empty($data['product_id'])) {
return (int) $data['product_id'];
}
$orderId = $data['order']['id'] ?? ($existing->order_id ?? null);
if ($orderId) {
$order = Order::find($orderId);
if ($order && !empty($order->product_id)) {
return (int) $order->product_id;
}
}
return null;
}
/**
* جهت سند معامله در زرین — از دید فروشگاه (نه مشتری).
* korf=k در عمل «خرید فی» می‌سازد: طلا بد + ریال بس (خرید فروشگاه از مشتری).
* خرید مشتری = فروش فروشگاه → f / parDar −1 : طلا بس + ریال بد
* فروش مشتری = خرید فروشگاه → k / parDar +1 : طلا بد + ریال بس
*
* @return array{korf: string, parDar: int}
*/
private function zarinShopSidesForCustomerTrade(bool $customerBuys): array
{
return $customerBuys
? ['korf' => 'f', 'parDar' => -1]
: ['korf' => 'k', 'parDar' => 1];
}
/** شرح سند ریالی زرین — همیشه با واریز/برداشت شروع شود تا «خرید فی» دیده نشود */
private function zarinRialWalletSharh(bool $isDeposit, ?string $note): string
{
$base = $isDeposit ? 'واریز' : 'برداشت';
$normalized = self::accountingWalletDesc($isDeposit, $note);
if ($normalized === '' || $normalized === $base) {
return $base;
}
if (str_starts_with($normalized, $base)) {
return $normalized;
}
return $base . ' — ' . $normalized;
}
2026-07-23 20:24:58 +03:30
public function storeTrade($data, ?Zarin $existing = null)
{
if (empty($data['customer_id']) && $existing && $existing->order_id) {
$order = Order::find($existing->order_id);
if ($order) {
$data = array_merge($data, [
'customer_id' => $existing->customer_id,
'amount' => $existing->amount,
'direction' => $order->direction,
'price' => $order->price,
'total_price' => $order->total_price,
'pay_at' => $existing->date,
'product' => [
'id' => $order->product_id,
'metal_id' => $existing->type === 'طلا' ? 1 : 2,
],
2026-07-28 17:44:24 +03:30
'product_id' => $order->product_id ? (int) $order->product_id : null,
'order' => ['id' => $existing->order_id, 'product_id' => $order->product_id],
2026-07-23 20:24:58 +03:30
]);
}
}
if (!isset($data['customer_id']) || !isset($data['amount'])) {
return request()->expectsJson()
? response()->json(['error' => 'Data missing: customer_id or amount'], 422)
: false;
}
2026-07-28 17:44:24 +03:30
$mapper = app(AccountingProductDocMapper::class);
$product = $mapper->resolveProductFromPayload($data);
if ($product && $mapper->shouldSkip($product)) {
return false;
}
2026-07-23 20:24:58 +03:30
$customer = Customer::where('id', $data['customer_id'])->first();
$accountOverride = trim((string) ($data['accounting_account_code'] ?? ''));
$isColleagueLeg = ! empty($data['colleague_leg']) && $accountOverride !== '';
2026-07-23 20:24:58 +03:30
if (! $isColleagueLeg) {
if ($customer && ! $customer->hasValidMoshtariCode()) {
$this->createCustomerInZarin($customer->id);
$customer = Customer::where('id', $data['customer_id'])->first();
}
if (!$customer || empty($customer->moshtari_code)) {
return false;
}
} elseif (! $customer) {
return false;
2026-07-23 20:24:58 +03:30
}
$accountCustomer = $customer;
if ($isColleagueLeg) {
$accountCustomer = clone $customer;
$accountCustomer->moshtari_code = $accountOverride;
2026-07-23 20:24:58 +03:30
}
2026-07-28 17:44:24 +03:30
$rawAmount = (float) $data['amount'];
$count = $mapper->accountingQty($product, $rawAmount, $data);
$vazn = $mapper->accountingWeight($product, $rawAmount, $data);
$isBuy = (int) ($data['direction'] ?? 0) === 1;
$sides = $this->zarinShopSidesForCustomerTrade($isBuy);
$orderId = is_object($data['order'] ?? null)
? ($data['order']->id ?? null)
: ($data['order']['id'] ?? ($existing->order_id ?? null));
2026-07-28 17:44:24 +03:30
$title = $mapper->productTitle($product);
$action = $isBuy ? 'خرید' : 'فروش';
$sharh = trim((string) ($data['sharh_override'] ?? ''));
if ($sharh === '') {
$sharh = trim($title !== '' ? "{$action} {$title}" : "{$action} {$mapper->kindLabel($mapper->resolveKind($product))}");
}
2026-07-28 17:44:24 +03:30
$syncAmount = $mapper->isCountDoc($product) ? $count : $vazn;
$uidSource = $orderId ?? null;
if (!empty($data['line'])) {
$uidSource = ($orderId ?? '0') . 'L' . (int) $data['line'];
}
if ($isColleagueLeg) {
$uidSource = ($uidSource ?? '0') . 'C';
}
2026-07-23 20:24:58 +03:30
$data['handler'] = 'storeTrade';
if ($existing && empty($existing->data)) {
$existing->update(['data' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)]);
}
$zarin = $existing ?? Zarin::create([
'customer_id' => $customer->id,
2026-07-28 17:44:24 +03:30
'desc' => $sharh,
'amount' => $syncAmount,
2026-07-23 20:24:58 +03:30
'order_id' => $orderId,
'date' => $data['pay_at'] ?? Carbon::now(),
2026-07-28 18:40:06 +03:30
'type' => $mapper->zarinSyncTypeLabel($product),
2026-07-23 20:24:58 +03:30
'created_at' => Carbon::now(),
'data' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
]);
2026-07-28 18:40:06 +03:30
if ($existing) {
$existing->update([
'type' => $mapper->zarinSyncTypeLabel($product),
'desc' => $sharh,
'amount' => $syncAmount,
]);
}
2026-07-23 20:24:58 +03:30
$uidKind = $isColleagueLeg ? '11' : '10';
2026-07-23 20:24:58 +03:30
try {
2026-07-28 17:44:24 +03:30
if ($mapper->isCountDoc($product)) {
$fi = (float) ($data['price'] ?? 0);
if ($count > 0) {
if (!empty($data['total_price'])) {
$fi = ((float) $data['total_price']) / $count;
} elseif ($fi > 0 && abs($vazn - $count) > 0.0001) {
$fi = $fi * ($vazn / $count);
}
2026-07-23 20:24:58 +03:30
}
2026-07-28 17:44:24 +03:30
$result = $this->postZarinCountDoc(
$zarin,
$data,
$accountCustomer,
2026-07-28 17:44:24 +03:30
$product,
$count,
$sides['parDar'],
2026-07-28 17:44:24 +03:30
$fi,
$sharh,
$this->zarinUid($uidKind, $uidSource ?? $zarin->id)
2026-07-28 17:44:24 +03:30
);
2026-07-23 20:24:58 +03:30
} else {
2026-07-28 17:44:24 +03:30
$result = $this->postZarinWeightDoc(
$zarin,
$data,
$accountCustomer,
2026-07-28 17:44:24 +03:30
$vazn,
$sides['korf'],
2026-07-28 17:44:24 +03:30
(float) ($data['price'] ?? 0),
$sharh,
$mapper->jensFelez($product) ?? 0,
$this->zarinUid($uidKind, $uidSource ?? $zarin->id)
2026-07-28 17:44:24 +03:30
);
2026-07-23 20:24:58 +03:30
}
} catch (\Exception $e) {
$this->sync()->markFailed($zarin, $e->getMessage());
return $e->getMessage();
}
2026-07-28 17:44:24 +03:30
return $result;
2026-07-23 20:24:58 +03:30
}
// direction: 0 = daryaft az moshtari, 1 = pardakht be moshtari
public function wallet($data, ?Zarin $existing = null)
{
if (empty($data['customer_id']) && $existing) {
$data = array_merge($data, [
'customer_id' => $existing->customer_id,
'value' => $existing->amount,
'direction' => str_contains((string) $existing->desc, 'برداشت') ? 1 : 0,
'withdraw_id' => $existing->withdraw_id,
]);
}
if (!isset($data['customer_id']) || !isset($data['value'])) {
return request()->expectsJson()
? response()->json(['error' => 'Data missing: customer_id or value'], 422)
: false;
}
$customer = Customer::where('id', $data['customer_id'])->first();
if ($customer && ! $customer->hasValidMoshtariCode()) {
2026-07-23 20:24:58 +03:30
$this->createCustomerInZarin($customer->id);
$customer = Customer::where('id', $data['customer_id'])->first();
}
if (!$customer || empty($customer->moshtari_code)) {
return false;
}
$data['handler'] = 'wallet';
2026-08-03 11:51:16 +03:30
$isDeposit = (int) ($data['direction'] ?? 0) === 0;
$sharh = $this->zarinRialWalletSharh($isDeposit, $data['note'] ?? null);
2026-07-23 20:24:58 +03:30
$zarin = $existing ?? Zarin::create([
'customer_id' => $customer->id,
2026-08-03 11:51:16 +03:30
'desc' => $sharh,
2026-07-23 20:24:58 +03:30
'amount' => $data['value'],
'type' => 'ريال',
'date' => Carbon::now(),
'created_at' => Carbon::now(),
'withdraw_id' => $data['withdraw_id'] ?? null,
'data' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
]);
if ($existing && empty($existing->data)) {
$existing->update(['data' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)]);
}
$isWithdraw = (int) ($data['direction'] ?? 0) === 1;
$uidSource = $data['withdraw_id'] ?? $zarin->id;
// kind=pooli فی خالی را با HTTP 400 «ERROR: fi is empty» رد می‌کند و فی باید در ۲۵٪ مظنه روز باشد.
// نتیجه همیشه نوع ۵۳ «پولی(خرید و فروش)» / عنوان «خرید فی» است (تبدیل ریال به گرم). نوع ۵۵ «وجه نقد» در API این پنل در دسترس نیست.
$prices = $this->lastPriceData();
$mazaneh = $isWithdraw ? (int) ($prices['sell'] ?? 0) : (int) ($prices['buy'] ?? 0);
if ($mazaneh <= 0) {
$this->sync()->markFailed($zarin, null, 'مظنه برای سند ریالی زرین در دسترس نیست');
return 'مظنه برای سند ریالی زرین در دسترس نیست.';
}
2026-07-23 20:24:58 +03:30
try {
$http = $this->zarinRequest([
'kind' => 'pooli',
'radif' => 1,
'korf' => $isWithdraw ? 'f' : 'k',
'poolorvazn' => 'pool',
'meghdar' => $data['value'],
'fi' => $mazaneh,
2026-07-23 20:24:58 +03:30
'hesab' => $customer->moshtari_code,
2026-08-03 11:51:16 +03:30
'sharh' => $sharh,
'ayar' => '750',
2026-07-23 20:24:58 +03:30
'movaghat' => '',
'idFactor' => '',
'uid' => $this->zarinUid('20', $uidSource),
]);
if (!$http) {
$this->sync()->markFailed($zarin, null, 'تنظیمات زرین کامل نیست');
return 'تنظیمات زرین کامل نیست. zarin_api یا zarin_token تنظیم نشده است.';
}
2026-07-28 14:02:23 +03:30
[$response, $httpError] = $this->zarinDecodeResponse($http);
if ($httpError) {
$this->sync()->markFailed($zarin, $response ?? $httpError, $httpError);
return false;
}
2026-07-23 20:24:58 +03:30
Log::info('Zarin wallet response', [
'data' => $data,
'response' => $response ?? null,
]);
} catch (\Exception $e) {
$this->sync()->markFailed($zarin, $e->getMessage());
return $e->getMessage();
}
if ($this->sync()->responseOk(is_array($response) ? $response : [])) {
$this->sync()->markSuccess($zarin, is_array($response) ? $response : [], $data);
return $data;
}
$this->sync()->markFailed($zarin, $response, 'ثبت سند ریالی در زرین ناموفق بود');
return false;
}
/**
* پاداش معرفی — همیشه روی حساب معرف (نه کاربر تازه‌ثبت‌نام‌شده).
*/
2026-08-03 11:51:16 +03:30
public function referrerGoldReward(Customer $invitedCustomer, float $gold, string $desc = 'پاداش معرفی')
2026-07-23 20:24:58 +03:30
{
$referrer = $invitedCustomer->referrer();
if (!$referrer) {
return false;
}
return $this->goldReward([
'customer_id' => $referrer->id,
'value' => $gold,
'desc' => $desc,
]);
}
/**
* پاداش/هدیه طلایی — سند وزنی با korf=f (هدیه به مشتری).
*/
public function goldReward($data, ?Zarin $existing = null)
{
if (empty($data['customer_id']) && $existing) {
$data = array_merge($data, [
'customer_id' => $existing->customer_id,
'value' => $existing->amount,
'desc' => $existing->desc,
]);
}
if (!isset($data['customer_id'], $data['value'])) {
return request()->expectsJson()
? response()->json(['error' => 'Data missing: customer_id or value'], 422)
: false;
}
$customer = Customer::where('id', $data['customer_id'])->first();
if (!$customer) {
return false;
}
if (! $customer->hasValidMoshtariCode()) {
2026-07-23 20:24:58 +03:30
$this->createCustomerInZarin($customer->id);
$customer = Customer::where('id', $data['customer_id'])->first();
}
if (!$customer || empty($customer->moshtari_code)) {
return false;
}
$amount = abs((float) $data['value']);
$desc = !empty($data['desc']) ? (string) $data['desc'] : 'پاداش';
$data['handler'] = 'goldReward';
$zarin = $existing ?? Zarin::create([
'customer_id' => $customer->id,
'desc' => $desc,
'amount' => $amount,
'date' => Carbon::now(),
'type' => 'طلا',
'created_at' => Carbon::now(),
'data' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
]);
if ($existing && empty($existing->data)) {
$existing->update(['data' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)]);
}
$prices = $this->lastPriceData();
$mazaneh = (int) ($prices['buy'] ?? 0);
if ($mazaneh <= 0) {
$mazaneh = 1;
}
try {
$http = $this->zarinRequest([
'kind' => 'pooli',
'radif' => 1,
'korf' => 'f',
'poolorvazn' => 'vazn',
'meghdar' => $amount,
'fi' => $mazaneh,
'hesab' => $customer->moshtari_code,
'sharh' => $desc,
'ayar' => '750',
'movaghat' => '',
'idFactor' => '',
'uid' => $this->zarinUid('50', $zarin->id),
]);
if (!$http) {
$this->sync()->markFailed($zarin, null, 'تنظیمات زرین کامل نیست');
return 'تنظیمات زرین کامل نیست. zarin_api یا zarin_token تنظیم نشده است.';
}
2026-07-28 14:02:23 +03:30
[$response, $httpError] = $this->zarinDecodeResponse($http);
if ($httpError) {
$this->sync()->markFailed($zarin, $response ?? $httpError, $httpError);
return false;
}
2026-07-23 20:24:58 +03:30
} catch (\Exception $e) {
$this->sync()->markFailed($zarin, $e->getMessage());
return $e->getMessage();
}
if ($this->sync()->responseOk(is_array($response) ? $response : [])) {
$this->sync()->markSuccess($zarin, is_array($response) ? $response : [], $data);
return $data;
}
$this->sync()->markFailed($zarin, $response, 'ثبت پاداش طلایی در زرین ناموفق بود');
return false;
}
2026-07-28 17:44:24 +03:30
// direction: 1 = واریز، 0 = برداشت
2026-07-23 20:24:58 +03:30
public function walletGold($data, ?Zarin $existing = null)
{
if (empty($data['customer_id']) && $existing) {
$data = array_merge($data, [
'customer_id' => $existing->customer_id,
'value' => $existing->amount,
'direction' => str_contains((string) $existing->desc, 'برداشت') ? 0 : 1,
'withdraw_id' => $existing->withdraw_id,
]);
2026-07-28 17:44:24 +03:30
$payload = json_decode((string) ($existing->data ?? ''), true);
if (is_array($payload)) {
if (!empty($payload['product_id'])) {
$data['product_id'] = (int) $payload['product_id'];
}
if (!empty($payload['product']) && is_array($payload['product'])) {
$data['product'] = $payload['product'];
}
}
2026-07-23 20:24:58 +03:30
}
if (!isset($data['customer_id'], $data['value'])) {
return request()->expectsJson()
? response()->json(['error' => 'Data missing: customer_id or value'], 422)
: false;
}
2026-07-28 17:44:24 +03:30
$mapper = app(AccountingProductDocMapper::class);
$product = $mapper->resolveProductFromPayload($data);
if ($product && $mapper->shouldSkip($product)) {
return false;
}
2026-07-23 20:24:58 +03:30
$customer = Customer::where('id', $data['customer_id'])->first();
if ($customer && ! $customer->hasValidMoshtariCode()) {
2026-07-23 20:24:58 +03:30
$this->createCustomerInZarin($customer->id);
$customer = Customer::where('id', $data['customer_id'])->first();
}
if (!$customer || empty($customer->moshtari_code)) {
return false;
}
2026-07-28 17:44:24 +03:30
$rawValue = (float) $data['value'];
$count = $mapper->accountingQty($product, $rawValue, $data);
$vazn = $mapper->accountingWeight($product, $rawValue, $data);
2026-07-23 20:24:58 +03:30
$isDeposit = (int) ($data['direction'] ?? 1) === 1;
2026-07-28 17:44:24 +03:30
$title = $mapper->productTitle($product);
2026-08-03 11:51:16 +03:30
$sharh = self::accountingWalletDesc(
$isDeposit,
!empty($data['note']) ? (string) $data['note'] : (!empty($data['desc']) ? (string) $data['desc'] : null)
);
2026-07-28 17:44:24 +03:30
$data['note'] = $sharh;
2026-07-23 20:24:58 +03:30
$data['handler'] = 'walletGold';
2026-07-28 17:44:24 +03:30
$syncAmount = $mapper->isCountDoc($product) ? $count : $vazn;
2026-07-23 20:24:58 +03:30
$zarin = $existing ?? Zarin::create([
'customer_id' => $customer->id,
2026-07-28 17:44:24 +03:30
'desc' => $sharh,
'amount' => $syncAmount,
2026-07-23 20:24:58 +03:30
'date' => Carbon::now(),
2026-07-28 18:40:06 +03:30
'type' => $mapper->zarinSyncTypeLabel($product),
2026-07-23 20:24:58 +03:30
'created_at' => Carbon::now(),
'withdraw_id' => $data['withdraw_id'] ?? null,
'data' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
]);
2026-07-28 18:40:06 +03:30
if ($existing) {
$existing->update([
'type' => $mapper->zarinSyncTypeLabel($product),
'desc' => $sharh,
'amount' => $syncAmount,
]);
}
2026-07-23 20:24:58 +03:30
if ($existing && empty($existing->data)) {
$existing->update(['data' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)]);
}
$prices = $this->lastPriceData();
$mazaneh = $isDeposit ? (int) ($prices['buy'] ?? 0) : (int) ($prices['sell'] ?? 0);
if ($mazaneh <= 0) {
$mazaneh = 1;
}
$uidSource = $data['withdraw_id'] ?? $zarin->id;
try {
2026-07-28 17:44:24 +03:30
if ($mapper->isCountDoc($product)) {
$unitPrice = $mazaneh;
if ($product && (float) ($product->amount ?? 0) > 0) {
$unitPrice = (int) round($mazaneh * (float) $product->amount);
}
if ($unitPrice <= 0) {
$unitPrice = $mazaneh;
}
2026-07-23 20:24:58 +03:30
2026-07-28 17:44:24 +03:30
return $this->postZarinCountDoc(
$zarin,
$data,
$customer,
$product,
$count,
$isDeposit ? -1 : 1,
(float) $unitPrice,
$sharh,
$this->zarinUid('21', $uidSource)
);
2026-07-23 20:24:58 +03:30
}
2026-07-28 17:44:24 +03:30
return $this->postZarinWeightDoc(
$zarin,
$data,
$customer,
$vazn,
$isDeposit ? 'f' : 'k',
(float) $mazaneh,
$sharh,
$mapper->jensFelez($product) ?? 0,
$this->zarinUid('21', $uidSource)
);
2026-07-23 20:24:58 +03:30
} catch (\Exception $e) {
$this->sync()->markFailed($zarin, $e->getMessage());
return $e->getMessage();
}
2026-07-28 17:44:24 +03:30
}
2026-07-23 20:24:58 +03:30
2026-07-28 17:44:24 +03:30
/**
* سند تعدادی زرین (سکه / شمش / گوی / مکعب).
2026-07-28 18:40:06 +03:30
* در زرین همه با sabtSanadTedadi می‌روند؛ تفکیک فقط با idKar جداگانهٔ هر محصول است.
2026-07-28 17:44:24 +03:30
*/
private function postZarinCountDoc(
Zarin $zarin,
array $data,
Customer $customer,
$product,
float $tedad,
int $parDar,
float $fi,
string $sharh,
string $uid
) {
if (!$product instanceof Product || empty($product->id)) {
$this->sync()->markFailed($zarin, null, 'محصول برای سند تعدادی زرین مشخص نیست');
return false;
}
if ($fi <= 0) {
$fi = 1;
}
2026-07-28 18:40:06 +03:30
$retriedInvalidKar = false;
2026-07-28 17:44:24 +03:30
2026-07-28 18:40:06 +03:30
while (true) {
$kar = $this->ensureZarinKarForProduct($product, $retriedInvalidKar);
if (empty($kar['ok']) || empty($kar['idKar'])) {
$this->sync()->markFailed($zarin, $kar ?? null, $kar['message'] ?? 'کد کار زرین برای محصول یافت نشد');
return false;
}
2026-07-28 17:44:24 +03:30
2026-07-28 18:55:59 +03:30
$idKar = (int) $kar['idKar'];
2026-07-28 18:40:06 +03:30
$http = $this->zarinRequest([
'kind' => 'sabtSanadTedadi',
'idHesab' => $customer->moshtari_code,
2026-07-28 18:55:59 +03:30
'idKar' => $idKar,
2026-07-28 18:40:06 +03:30
'fi' => $fi,
'tedad' => $tedad,
'parDar' => $parDar,
'sharh' => $sharh,
'uid' => $uid,
]);
if (!$http) {
$this->sync()->markFailed($zarin, null, 'تنظیمات زرین کامل نیست');
return 'تنظیمات زرین کامل نیست. zarin_api یا zarin_token تنظیم نشده است.';
}
[$response, $httpError] = $this->zarinDecodeResponse($http);
2026-07-28 18:55:59 +03:30
$diag = 'idKar=' . $idKar
2026-07-28 19:08:04 +03:30
. ' noeKar=' . $this->zarinCountNoeKar()
2026-07-28 18:55:59 +03:30
. ' goroheKar=' . $this->zarinDefaultGoroheKar()
. ' product=' . ($product->title ?? '')
. ' source=' . ($kar['source'] ?? '');
2026-07-28 18:40:06 +03:30
if (
!$retriedInvalidKar
&& $this->isInvalidZarinKarError($response, $httpError)
) {
2026-07-28 18:55:59 +03:30
Log::warning('zarin sabtSanadTedadi invalid idKar, recreating', [
'diag' => $diag,
'response' => $response,
'httpError' => $httpError,
]);
2026-07-28 18:40:06 +03:30
$this->clearProductZarinKar($product);
$product->refresh();
$retriedInvalidKar = true;
continue;
}
if ($httpError) {
2026-07-28 18:55:59 +03:30
$this->sync()->markFailed(
$zarin,
$response ?? $httpError,
$httpError . ' | ' . $diag
);
2026-07-28 18:40:06 +03:30
return false;
}
$flat = $this->extractResponseRow(is_array($response) ? $response : null);
if ($this->sync()->responseOk(is_array($response) ? $response : []) || $this->sync()->responseOk($flat)) {
$this->sync()->markSuccess($zarin, is_array($response) ? $response : $flat, $data);
return $data;
}
if (
!$retriedInvalidKar
&& $this->isInvalidZarinKarError($response)
) {
2026-07-28 18:55:59 +03:30
Log::warning('zarin sabtSanadTedadi invalid idKar (HTTP 200), recreating', [
'diag' => $diag,
'response' => $response,
]);
2026-07-28 18:40:06 +03:30
$this->clearProductZarinKar($product);
$product->refresh();
$retriedInvalidKar = true;
continue;
}
2026-07-28 18:55:59 +03:30
$this->sync()->markFailed(
$zarin,
$response,
'ثبت سند تعدادی در زرین ناموفق بود | ' . $diag
);
2026-07-28 17:44:24 +03:30
return false;
}
}
/**
* سند وزنی زرین (آبشده طلا/نقره).
*/
private function postZarinWeightDoc(
Zarin $zarin,
array $data,
Customer $customer,
float $meghdar,
string $korf,
float $fi,
string $sharh,
int $jensFelez,
string $uid
) {
if ($fi <= 0) {
$fi = 1;
}
$http = $this->zarinRequest([
'kind' => 'pooli',
'radif' => 1,
'korf' => $korf,
'poolorvazn' => 'vazn',
'meghdar' => $meghdar,
'fi' => $fi,
'hesab' => $customer->moshtari_code,
'sharh' => $sharh,
'ayar' => $jensFelez === 2 ? '' : '750',
'movaghat' => '',
'idFactor' => '',
'uid' => $uid,
]);
if (!$http) {
$this->sync()->markFailed($zarin, null, 'تنظیمات زرین کامل نیست');
return 'تنظیمات زرین کامل نیست. zarin_api یا zarin_token تنظیم نشده است.';
}
[$response, $httpError] = $this->zarinDecodeResponse($http);
if ($httpError) {
$this->sync()->markFailed($zarin, $response ?? $httpError, $httpError);
return false;
}
$flat = $this->extractResponseRow(is_array($response) ? $response : null);
if ($this->sync()->responseOk(is_array($response) ? $response : []) || $this->sync()->responseOk($flat)) {
$this->sync()->markSuccess($zarin, is_array($response) ? $response : $flat, $data);
return $data;
}
$this->sync()->markFailed($zarin, $response, 'ثبت سند وزنی در زرین ناموفق بود');
2026-07-23 20:24:58 +03:30
return false;
}
public function createCustomerInZarin($customer_id)
{
$customer = Customer::findOrFail($customer_id);
if ($customer->hasValidMoshtariCode()) {
2026-07-23 20:24:58 +03:30
return $customer->moshtari_code;
}
$lock = Cache::lock('zarin:register-customer:' . $customer->id, 90);
2026-07-23 20:24:58 +03:30
try {
$lock->block(25);
} catch (LockTimeoutException $e) {
2026-07-23 20:24:58 +03:30
$customer->refresh();
if ($customer->hasValidMoshtariCode()) {
2026-07-23 20:24:58 +03:30
return $customer->moshtari_code;
}
return 'ثبت مشتری در زرین در حال انجام است';
}
try {
$customer->refresh();
if ($customer->hasValidMoshtariCode()) {
2026-07-23 20:24:58 +03:30
return $customer->moshtari_code;
}
$existingId = $this->findExistingZarinHesabId($customer->mobile);
if ($existingId !== null) {
return $this->bindCustomerZarinHesab($customer, $existingId);
}
2026-07-23 20:24:58 +03:30
$name = $customer->fname ?: ($customer->name ?? ('مشتری ' . $customer->id));
$family = $customer->lname ?: '';
$mobile = $customer->mobile ?? '';
$params = [
'kind' => 'addhesab',
'name' => $name,
'family' => $family,
'tel' => $mobile,
'mobile' => $mobile,
'address' => trim(($customer->province ?? '') . ' ' . ($customer->city ?? '')) ?: 'ثبت نشده',
'codePosti' => '0000000000',
'nameG' => $this->zarinCustomerGroupName(),
'gender' => in_array($customer->gender, ['اقای', 'آقای'], true) ? 'm' : ($customer->gender == 'خانم' ? 'f' : ''),
'uid' => $this->zarinUid('40', $customer->id),
];
try {
$http = $this->zarinRequest($params);
if (!$http) {
return 'تنظیمات زرین کامل نیست. zarin_api یا zarin_token تنظیم نشده است.';
}
2026-07-28 14:02:23 +03:30
[$response, $httpError] = $this->zarinDecodeResponse($http);
if ($httpError) {
return $httpError;
}
2026-07-23 20:24:58 +03:30
2026-07-28 14:02:23 +03:30
$data = $this->extractResponseRow($response);
$idHesab = $this->extractZarinHesabId($data);
2026-07-23 20:24:58 +03:30
if ($idHesab !== null) {
return $this->bindCustomerZarinHesab($customer, $idHesab);
}
2026-07-28 14:15:51 +03:30
$existingId = $this->findExistingZarinHesabId($customer->mobile);
if ($existingId !== null) {
return $this->bindCustomerZarinHesab($customer, $existingId);
2026-07-23 20:24:58 +03:30
}
2026-07-28 14:02:23 +03:30
$detail = $data['msg'] ?? $data['message'] ?? $data['detail'] ?? null;
return $detail ? (string) $detail : 'zarin api error!!!';
2026-07-23 20:24:58 +03:30
} catch (\Exception $e) {
return $e->getMessage();
}
} finally {
$lock->release();
}
}
public function healthCheck()
{
$http = $this->zarinRequest([
'kind' => 'version',
]);
2026-07-28 14:02:23 +03:30
[$json, $error] = $this->zarinDecodeResponse($http);
if ($error) {
return response()->json([
'ok' => false,
'message' => $error,
'body' => $json,
], 422);
2026-07-23 20:24:58 +03:30
}
2026-07-28 14:02:23 +03:30
return response()->json([
'ok' => true,
'version' => $json['version'] ?? null,
'message' => $json['message'] ?? 'OK',
'raw' => $json,
]);
2026-07-23 20:24:58 +03:30
}
}