diff --git a/.cursor/skills/abshode-domain-lookup/learned-notes.md b/.cursor/skills/abshode-domain-lookup/learned-notes.md index 9039403..5ecfc78 100644 --- a/.cursor/skills/abshode-domain-lookup/learned-notes.md +++ b/.cursor/skills/abshode-domain-lookup/learned-notes.md @@ -3,6 +3,7 @@ فرمت هر خط: - YYYY-MM-DD | موضوع | مسیر فایل(ها) | نکتهٔ ۱ خطی +- 2026-10-05 | شایگان حسابداری | `ConfigService::accountingProvider` همیشه shayegan + `ShayeganSyncService` | Ws: `2.180.4.172:2030` (عمومی) / `192.168.109.222:2030` (داخلی); SQL Config P-SERVER/CYPRSD04; ارسال وقتی mapping کامل باشد - 2026-09-06 | گیفت‌کارت | `database/migrations/2026_09_06_000001_create_giftcards_tables.php` | create جداول giftcards و giftcard_codes اگر نباشند؛ drop در down ندارد - 2026-09-08 | خطای «محدودیت فیلد قیمت» در گیفت‌کارت | `app/Exceptions/Handler.php` + `GiftCardController::redeem` | پیام عمومی MySQL ۱۲۶۴ (Out of range) است؛ معمولاً `amount`/`total=count*amount` از ظرفیت ستون بیشتر است، نه باگ فرم ادمین - 2026-09-08 | widen مبلغ گیفت‌کارت | `migrations/2026_09_08_000001_widen_giftcard_amount_columns.php` + `deploy_safe_schema.sql` | `amount`/`total` → DECIMAL(20,4) تا مبالغ ≥۱۰۰ میلیون ریال جا شود diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index 59299da..7489a90 100644 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -81,7 +81,7 @@ class SettingsController extends Controller ->orderBy('key') ->get(); - return response()->json($rows); + return response()->json($this->maskSecretSettingValues($rows)); } /** @@ -162,7 +162,19 @@ class SettingsController extends Controller $previousValue = $setting->exists ? (string) ($setting->value ?? '') : null; - $setting->value = $request->value; + $incomingValue = $request->value; + if ( + in_array($key, ['shayegan_db_password', 'shayegan_auth_password'], true) + && is_string($incomingValue) + && ($incomingValue === '********' || $incomingValue === '') + && $previousValue !== null + && $previousValue !== '' + ) { + // ماسک frontend را به‌جای رمز واقعی ذخیره نکن + $incomingValue = $previousValue; + } + + $setting->value = $incomingValue; $setting->description = $request->description; if ($request->has('is_active')) { $setting->is_active = filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN) ? 1 : 0; @@ -327,7 +339,7 @@ class SettingsController extends Controller $overlay = app(SuperAdminProgramSettingsOverlay::class); $saMap = $overlay->catalogMap(); if ($saMap === null) { - return $localRows; + return $this->maskSecretSettingValues($localRows); } $localByKey = []; @@ -380,7 +392,53 @@ class SettingsController extends Controller } } - return collect(array_values($byKey))->sortBy(fn ($row) => (string) ($row->key ?? ''))->values(); + return $this->maskSecretSettingValues( + collect(array_values($byKey))->sortBy(fn ($row) => (string) ($row->key ?? ''))->values() + ); + } + + /** + * رمزهای شایگان و مشابه در پاسخ frontend برنگردند (فقط پر/خالی بودن). + * + * @param mixed $rows + * @return mixed + */ + private function maskSecretSettingValues($rows) + { + $secretKeys = [ + 'shayegan_db_password', + 'shayegan_auth_password', + ]; + + $maskOne = function ($row) use ($secretKeys) { + if (is_array($row)) { + $key = (string) ($row['key'] ?? ''); + if (in_array($key, $secretKeys, true)) { + $val = (string) ($row['value'] ?? ''); + $row['value'] = $val !== '' ? '********' : ''; + } + + return $row; + } + if (is_object($row) && isset($row->key)) { + $key = (string) $row->key; + if (in_array($key, $secretKeys, true)) { + $val = (string) ($row->value ?? ''); + $row->value = $val !== '' ? '********' : ''; + } + } + + return $row; + }; + + if ($rows instanceof \Illuminate\Support\Collection) { + return $rows->map($maskOne)->values(); + } + if (is_array($rows)) { + return array_map($maskOne, $rows); + } + + return $rows; } private function isEditableCanEdit(mixed $canEdit): bool diff --git a/app/Http/Controllers/Admin/ShayeganController.php b/app/Http/Controllers/Admin/ShayeganController.php new file mode 100644 index 0000000..05cda9b --- /dev/null +++ b/app/Http/Controllers/Admin/ShayeganController.php @@ -0,0 +1,171 @@ +sync = $sync; + $this->client = $client; + } + + public function healthCheck() + { + $result = $this->client->healthCheck(); + $status = ! empty($result['ok']) ? 200 : 422; + + return response()->json([ + 'ok' => ! empty($result['ok']), + 'message' => $result['message'] ?? null, + 'http_status' => $result['http_status'] ?? null, + 'timeout' => ! empty($result['timeout']), + // never echo Config passwords + 'body' => $this->sanitizeBodyForAdmin($result['body'] ?? null), + ], $status); + } + + /** + * @param array $data + */ + public function storeTrade(array $data) + { + return $this->sync->recordTrade($data); + } + + /** + * @param array $data + */ + public function wallet(array $data) + { + return $this->sync->recordWalletCash($data); + } + + /** + * @param array $data + */ + public function walletGold(array $data) + { + return $this->sync->recordWalletGold($data); + } + + public function table(Request $request) + { + if (! Schema::hasTable('shayegan')) { + return response()->json(['data' => [], 'total' => 0]); + } + + $q = Shayegan::query()->orderByDesc('id'); + if ($request->filled('sync_status')) { + $q->where('sync_status', $request->get('sync_status')); + } + if ($request->filled('type')) { + $q->where('type', $request->get('type')); + } + + $perPage = min(100, max(10, (int) $request->get('per_page', 20))); + $page = $q->paginate($perPage); + + return response()->json([ + 'data' => $page->items(), + 'total' => $page->total(), + 'current_page' => $page->currentPage(), + 'per_page' => $page->perPage(), + ]); + } + + public function show($id) + { + $row = Shayegan::findOrFail($id); + + return response()->json($row); + } + + /** + * Manual retry — refuses timeout rows unless force=1 after operator verified remote. + */ + public function retrySanad(Request $request, $id) + { + $row = Shayegan::findOrFail($id); + if ($row->sync_status === ShayeganSyncService::STATUS_SUCCESS) { + return response()->json(['ok' => true, 'message' => 'already synced', 'row' => $row]); + } + if ($row->sync_status === ShayeganSyncService::STATUS_TIMEOUT && ! $request->boolean('force')) { + return response()->json([ + 'ok' => false, + 'message' => 'این سند timeout شده؛ بدون بررسی نتیجه در حسابگر دوباره ارسال نکنید. در صورت تایید force=1 بفرستید.', + ], 422); + } + + $payload = json_decode((string) $row->data, true); + $put = is_array($payload) ? ($payload['PutObject'] ?? null) : null; + $endpoint = (string) ($row->endpoint ?: 'Document/Put'); + if (! is_array($put) || $put === []) { + return response()->json(['ok' => false, 'message' => 'payload خالی است'], 422); + } + + // Clear timeout lock only when force retry requested + if ($row->sync_status === ShayeganSyncService::STATUS_TIMEOUT) { + $row->sync_status = ShayeganSyncService::STATUS_PENDING; + $row->save(); + } + + $result = $this->client->put($endpoint, $put); + if (! empty($result['timeout'])) { + $row->sync_status = ShayeganSyncService::STATUS_TIMEOUT; + $row->sync_log = (string) ($result['message'] ?? 'timeout'); + $row->res = json_encode(['timeout' => true], JSON_UNESCAPED_UNICODE); + $row->save(); + + return response()->json(['ok' => false, 'message' => 'timeout', 'row' => $row], 422); + } + + if (! empty($result['ok'])) { + $row->sync_status = ShayeganSyncService::STATUS_SUCCESS; + $row->sync_log = null; + $row->shayegan_number = $result['number'] ?? null; + $row->shayegan_guid = $result['guid'] ?? null; + $row->res = json_encode($result['body'] ?? [], JSON_UNESCAPED_UNICODE); + $row->save(); + + return response()->json(['ok' => true, 'row' => $row]); + } + + $row->sync_status = ShayeganSyncService::STATUS_FAILED; + $row->sync_log = (string) ($result['message'] ?? 'failed'); + $row->res = json_encode($result['body'] ?? [], JSON_UNESCAPED_UNICODE); + $row->save(); + + return response()->json(['ok' => false, 'message' => $row->sync_log, 'row' => $row], 422); + } + + /** + * @param mixed $body + * @return mixed + */ + private function sanitizeBodyForAdmin($body) + { + if (! is_array($body)) { + return $body; + } + // Drop huge Result lists from health if present + if (isset($body['Result']) && is_array($body['Result'])) { + $body['Result'] = array_slice($body['Result'], 0, 3); + } + + return $body; + } +} diff --git a/app/Http/Controllers/Admin/WalletController.php b/app/Http/Controllers/Admin/WalletController.php index fcabfc2..ecd3ff9 100644 --- a/app/Http/Controllers/Admin/WalletController.php +++ b/app/Http/Controllers/Admin/WalletController.php @@ -284,6 +284,16 @@ class WalletController extends Controller } } + if (app(ConfigService::class)->isShayeganAccounting()) { + try { + $data['direction'] = $deposit ? 0 : 1; + $data['withdraw_id'] = $Withdraw->id; + app(\App\Http\Controllers\Admin\ShayeganController::class)->wallet($data); + } catch (\Exception $e) { + report($e); + } + } + try { app(PargarSyncService::class)->queueWalletDocument( $customer, @@ -430,6 +440,16 @@ class WalletController extends Controller } } + if (app(ConfigService::class)->isShayeganAccounting()) { + try { + $data['direction'] = $deposit ? 1 : 0; + $data['withdraw_id'] = $Withdraw->id; + app(\App\Http\Controllers\Admin\ShayeganController::class)->walletGold($data); + } catch (\Exception $e) { + report($e); + } + } + try { app(PargarSyncService::class)->queueWalletDocument( $customer, @@ -669,6 +689,17 @@ class WalletController extends Controller } } + if (app(ConfigService::class)->isShayeganAccounting()) { + try { + $data['direction'] = $deposit ? 1 : 0; + $data['withdraw_id'] = $Withdraw->id; + $data['product_id'] = $productId; + app(\App\Http\Controllers\Admin\ShayeganController::class)->walletGold($data); + } catch (\Exception $e) { + report($e); + } + } + try { // فقط محصول متصل به کیف اصلی → سند طلای پرگار app(PargarSyncService::class)->queueWalletDocument( diff --git a/app/Http/Controllers/Customer/CartController.php b/app/Http/Controllers/Customer/CartController.php index 4bb96a1..de39f39 100644 --- a/app/Http/Controllers/Customer/CartController.php +++ b/app/Http/Controllers/Customer/CartController.php @@ -445,6 +445,14 @@ class CartController extends Controller report($e); } } + + if ($config->isShayeganAccounting()) { + try { + app(\App\Http\Controllers\Admin\ShayeganController::class)->storeTrade($payload); + } catch (\Throwable $e) { + report($e); + } + } } if ($config->isPargarAccounting()) { diff --git a/app/Http/Controllers/Customer/WalletController.php b/app/Http/Controllers/Customer/WalletController.php index deafc67..9104ce3 100644 --- a/app/Http/Controllers/Customer/WalletController.php +++ b/app/Http/Controllers/Customer/WalletController.php @@ -486,6 +486,20 @@ class WalletController extends Controller } } + if (app(ConfigService::class)->isShayeganAccounting()) { + try { + app(\App\Http\Controllers\Admin\ShayeganController::class)->wallet([ + 'customer_id' => auth('customer')->id(), + 'value' => $amount, + 'direction' => 1, + 'withdraw_id' => $Withdraw->id, + 'note' => 'برداشت ریالی', + ]); + } catch (\Exception $e) { + report($e); + } + } + $companyName = $this->Config('name_fa'); // Company name from configuration $message = "کاربر $userName با شماره موبایل $userMobile درخواست برداشت مبلغ " . number_format($withdrawAmount) . " ریال را دارد. با تشکر، $companyName"; User::where('send_sms', 1)->get()->each(function ($admin) use ($message) { diff --git a/app/Http/Controllers/Single/SingleOrderController.php b/app/Http/Controllers/Single/SingleOrderController.php index 431ec7b..7d09ae6 100644 --- a/app/Http/Controllers/Single/SingleOrderController.php +++ b/app/Http/Controllers/Single/SingleOrderController.php @@ -2362,6 +2362,14 @@ class SingleOrderController extends Controller } } + if ($config->isShayeganAccounting()) { + try { + $data['shayegan'] = app(\App\Http\Controllers\Admin\ShayeganController::class)->storeTrade($data); + } catch (\Throwable $e) { + report($e); + } + } + if (isset($data['order'])) { try { app(\App\Services\ColleagueAccountingService::class)->recordCoveredTrade($data); diff --git a/app/Models/Shayegan.php b/app/Models/Shayegan.php new file mode 100644 index 0000000..51a0d50 --- /dev/null +++ b/app/Models/Shayegan.php @@ -0,0 +1,17 @@ + 'datetime', + 'amount' => 'float', + ]; +} diff --git a/app/Services/AccountingWalletPush.php b/app/Services/AccountingWalletPush.php index 8156165..763e849 100644 --- a/app/Services/AccountingWalletPush.php +++ b/app/Services/AccountingWalletPush.php @@ -59,5 +59,13 @@ class AccountingWalletPush report($e); } } + + if ($config->isShayeganAccounting()) { + try { + app(\App\Http\Controllers\Admin\ShayeganController::class)->walletGold($payload); + } catch (\Throwable $e) { + report($e); + } + } } } diff --git a/app/Services/ConfigService.php b/app/Services/ConfigService.php index 364a81b..3c4fa76 100644 --- a/app/Services/ConfigService.php +++ b/app/Services/ConfigService.php @@ -1261,6 +1261,7 @@ class ConfigService public const ACCOUNTING_TAHESAB = 'tahesab'; public const ACCOUNTING_PARGAR = 'pargar'; public const ACCOUNTING_ZARIN = 'zarin'; + public const ACCOUNTING_SHAYEGAN = 'shayegan'; public function isConditionalOrderEnabled(): bool { @@ -1311,22 +1312,13 @@ class ConfigService return max(0, $this->getInt('fee_conditional_order', 0)); } - /** none | tahesab | pargar | zarin — از setting accounting_provider */ + /** + * بورس مس: حسابداری بیرونی همیشه شایگان است (مستقل از accounting_provider / legacy flags). + * ارسال واقعی فقط وقتی shayegan_* در settings کامل باشد (ShayeganSyncService::isSyncEnabled). + */ public function accountingProvider(): string { - $provider = strtolower(trim($this->getString('accounting_provider', ''))); - if (in_array($provider, [self::ACCOUNTING_NONE, self::ACCOUNTING_TAHESAB, self::ACCOUNTING_PARGAR, self::ACCOUNTING_ZARIN], true)) { - return $provider; - } - - if ($this->getString('send_to_pargar', 'false') === 'true') { - return self::ACCOUNTING_PARGAR; - } - if ($this->getString('send_to_tahesab', 'false') === 'true') { - return self::ACCOUNTING_TAHESAB; - } - - return self::ACCOUNTING_NONE; + return self::ACCOUNTING_SHAYEGAN; } public function isTahesabAccounting(): bool @@ -1344,6 +1336,11 @@ class ConfigService return $this->accountingProvider() === self::ACCOUNTING_ZARIN; } + public function isShayeganAccounting(): bool + { + return $this->accountingProvider() === self::ACCOUNTING_SHAYEGAN; + } + /** * Hostname for WebOTP SMS (@domain #code) and other public links. * Prefer settings.app_url (دامنهٔ اپ کاربر) over the API request host — diff --git a/app/Services/Payment/WalletDepositCreditor.php b/app/Services/Payment/WalletDepositCreditor.php index 7c7187f..0a49026 100644 --- a/app/Services/Payment/WalletDepositCreditor.php +++ b/app/Services/Payment/WalletDepositCreditor.php @@ -125,5 +125,13 @@ class WalletDepositCreditor report($e); } } + + if ($this->config->isShayeganAccounting()) { + try { + app(\App\Http\Controllers\Admin\ShayeganController::class)->wallet($payload); + } catch (\Throwable $e) { + report($e); + } + } } } diff --git a/app/Services/ShayeganClient.php b/app/Services/ShayeganClient.php new file mode 100644 index 0000000..becfe4d --- /dev/null +++ b/app/Services/ShayeganClient.php @@ -0,0 +1,266 @@ +config = $config; + } + + public function isConfigured(): bool + { + return $this->baseUrl() !== '' + && $this->config->getString('shayegan_sql_server', '') !== '' + && $this->config->getString('shayegan_database', '') !== '' + && $this->config->getString('shayegan_db_user', '') !== ''; + } + + public function baseUrl(): string + { + return rtrim(trim($this->config->getString('shayegan_base_url', '')), '/'); + } + + /** + * @return array + */ + public function buildConfig(): array + { + $authUser = trim($this->config->getString('shayegan_auth_user', '')); + $authPass = (string) $this->config->getString('shayegan_auth_password', ''); + + return [ + 'Server' => $this->config->getString('shayegan_sql_server', ''), + 'AllowRowSecurity' => false, + 'Level' => 0, + 'DBUserName' => $this->config->getString('shayegan_db_user', ''), + 'DBPassword' => (string) $this->config->getString('shayegan_db_password', ''), + 'DataBaseName' => $this->config->getString('shayegan_database', ''), + 'AuthUser' => $authUser !== '' ? $authUser : '', + 'AuthPassword' => $authUser !== '' ? $authPass : '', + 'Language' => $this->config->getInt('shayegan_language', 3), + ]; + } + + /** + * Read-only smoke test: Account/Get one page. + * + * @return array{ok:bool,message:string,http_status:?int,body:?array,timeout?:bool} + */ + public function healthCheck(): array + { + if (! $this->isConfigured()) { + return [ + 'ok' => false, + 'message' => 'تنظیمات اتصال شایگان ناقص است (base_url / Server / DataBase / DBUser).', + 'http_status' => null, + 'body' => null, + ]; + } + + return $this->get('Account/Get', [], 0, 1); + } + + /** + * @param array $domain + * @return array{ok:bool,message:string,http_status:?int,body:?array,timeout?:bool} + */ + public function get(string $endpoint, array $domain = [], int $rowStart = 0, int $rowCount = 1): array + { + $payload = [ + 'Config' => $this->buildConfig(), + 'Domain' => $domain, + ]; + + return $this->request('GET_STYLE', $endpoint, $payload, [ + 'RowStart' => $rowStart, + 'RowCount' => $rowCount, + ]); + } + + /** + * @param array> $putObject + * @return array{ok:bool,message:string,http_status:?int,body:?array,timeout?:bool,number:?string,guid:?string} + */ + public function put(string $endpoint, array $putObject): array + { + $payload = [ + 'Config' => $this->buildConfig(), + 'PutObject' => array_values($putObject), + ]; + + $result = $this->request('PUT_STYLE', $endpoint, $payload, []); + if (! empty($result['body']) && is_array($result['body'])) { + $first = $this->firstResultRow($result['body']); + $result['number'] = isset($first['Number']) ? (string) $first['Number'] : null; + $result['guid'] = isset($first['GuId']) ? (string) $first['GuId'] : (isset($first['Guid']) ? (string) $first['Guid'] : null); + } else { + $result['number'] = null; + $result['guid'] = null; + } + + return $result; + } + + /** + * @param array $payload + * @param array $query + * @return array{ok:bool,message:string,http_status:?int,body:?array,timeout?:bool} + */ + private function request(string $style, string $endpoint, array $payload, array $query): array + { + $url = $this->endpointUrl($endpoint, $query); + $timeout = max(5, $this->config->getInt('shayegan_timeout_seconds', 30)); + + try { + $response = Http::timeout($timeout) + ->withHeaders(['Content-Type' => 'application/json', 'Accept' => 'application/json']) + ->post($url, $payload); + + $status = $response->status(); + $json = $response->json(); + if (! is_array($json)) { + $raw = (string) $response->body(); + $decoded = json_decode($raw, true); + $json = is_array($decoded) ? $decoded : ['raw' => Str::limit($raw, 2000)]; + } + + $error = $this->extractErrorMessage($json); + if ($status >= 400) { + return [ + 'ok' => false, + 'message' => $error !== '' ? $error : ('HTTP ' . $status), + 'http_status' => $status, + 'body' => $json, + ]; + } + + if ($error !== '') { + return [ + 'ok' => false, + 'message' => $error, + 'http_status' => $status, + 'body' => $json, + ]; + } + + return [ + 'ok' => true, + 'message' => 'OK', + 'http_status' => $status, + 'body' => $json, + ]; + } catch (\Illuminate\Http\Client\ConnectionException $e) { + Log::warning('shayegan.timeout_or_connection', [ + 'endpoint' => $endpoint, + 'style' => $style, + 'message' => $e->getMessage(), + ]); + + return [ + 'ok' => false, + 'message' => 'timeout/connection: ' . $e->getMessage(), + 'http_status' => null, + 'body' => null, + 'timeout' => true, + ]; + } catch (\Throwable $e) { + Log::warning('shayegan.request_failed', [ + 'endpoint' => $endpoint, + 'message' => $e->getMessage(), + ]); + + return [ + 'ok' => false, + 'message' => $e->getMessage(), + 'http_status' => null, + 'body' => null, + ]; + } + } + + /** + * @param array $query + */ + private function endpointUrl(string $endpoint, array $query): string + { + $endpoint = ltrim(str_replace('\\', '/', $endpoint), '/'); + $url = $this->baseUrl() . '/api/' . $endpoint; + if ($query !== []) { + $url .= (strpos($url, '?') === false ? '?' : '&') . http_build_query($query); + } + + return $url; + } + + /** + * @param array $body + */ + public function extractErrorMessage(array $body): string + { + $top = trim((string) ($body['ErrorMessage'] ?? '')); + if ($top !== '') { + return $top; + } + + $row = $this->firstResultRow($body); + if ($row !== null) { + $rowError = trim((string) ($row['ErrorMessage'] ?? '')); + if ($rowError !== '') { + return $rowError; + } + } + + return ''; + } + + /** + * @param array $body + * @return array|null + */ + private function firstResultRow(array $body): ?array + { + $result = $body['Result'] ?? null; + if (! is_array($result) || $result === []) { + return null; + } + + $first = isset($result[0]) && is_array($result[0]) ? $result[0] : null; + if ($first !== null) { + return $first; + } + + return is_array($result) && ! isset($result[0]) ? $result : null; + } + + /** + * Strip secrets before logging / storing in admin-visible res when needed. + * + * @param array $payload + * @return array + */ + public function redactPayload(array $payload): array + { + if (isset($payload['Config']) && is_array($payload['Config'])) { + foreach (['DBPassword', 'AuthPassword'] as $key) { + if (array_key_exists($key, $payload['Config']) && $payload['Config'][$key] !== '' && $payload['Config'][$key] !== null) { + $payload['Config'][$key] = '***'; + } + } + } + + return $payload; + } +} diff --git a/app/Services/ShayeganSyncService.php b/app/Services/ShayeganSyncService.php new file mode 100644 index 0000000..8e07cf4 --- /dev/null +++ b/app/Services/ShayeganSyncService.php @@ -0,0 +1,475 @@ +config = $config; + $this->client = $client; + } + + public function isSyncEnabled(): bool + { + return $this->config->isShayeganAccounting() + && $this->client->isConfigured(); + } + + public function tradeMappingReady(): bool + { + return $this->config->getString('shayegan_item_copper', '') !== '' + && $this->config->getString('shayegan_stock_number', '') !== ''; + } + + public function cashMappingReady(): bool + { + return $this->config->getString('shayegan_bank_account', '') !== ''; + } + + public function metalWalletMode(): string + { + $mode = strtolower(trim($this->config->getString('shayegan_metal_wallet_mode', 'skip'))); + if (in_array($mode, ['skip', 'document', 'receipt_havale'], true)) { + return $mode; + } + + return 'skip'; + } + + /** + * After confirmed trade: one Invoice only (no duplicate Document). + * Customer buy → company sale (InvTyp 2); customer sell → company purchase (InvTyp 3). + * + * @param array $data + */ + public function recordTrade(array $data): ?Shayegan + { + if (! $this->isSyncEnabled()) { + return null; + } + if (! $this->tradeMappingReady()) { + Log::info('shayegan.trade_skipped_mapping', ['order_id' => $data['order']->id ?? null]); + return null; + } + + $order = $data['order'] ?? null; + $orderId = $order ? (int) $order->id : (int) ($data['order_id'] ?? 0); + $customerId = (int) ($data['customer_id'] ?? ($order->customer_id ?? 0)); + if ($customerId <= 0) { + return null; + } + + $existing = $this->findExisting(self::TYPE_TRADE, $orderId > 0 ? $orderId : null, null, $customerId); + if ($existing && $this->shouldSkipSend($existing)) { + return $existing; + } + + $customer = Customer::find($customerId); + $accountNumber = $this->customerAccountNumber($customer); + if ($accountNumber === '') { + return $this->markLocalFailed($existing, $customerId, $orderId, null, self::TYPE_TRADE, 'کد حساب مشتری در شایگان خالی است (moshtari_code).', null); + } + + $isBuy = (int) ($data['direction'] ?? 0) === 1; + $invTyp = $isBuy ? self::INV_SALE : self::INV_PURCHASE; + $quan = $this->scaleWeight((float) ($data['number'] ?? $data['amount'] ?? 0)); + $price = $this->scaleAmount((float) ($data['price'] ?? $data['fee'] ?? 0)); + if ($quan <= 0) { + return $this->markLocalFailed($existing, $customerId, $orderId, null, self::TYPE_TRADE, 'مقدار وزن معامله برای شایگان صفر است.', null); + } + + $guid = $existing && $existing->request_guid + ? (string) $existing->request_guid + : (string) Str::uuid(); + + $today = date('Ymd'); + $put = [[ + 'Body' => [[ + 'STNumber' => $this->config->getString('shayegan_stock_number', ''), + 'STGuId' => null, + 'ItemNumber' => $this->config->getString('shayegan_item_copper', ''), + 'ItemGuId' => null, + 'Quan' => $quan, + 'Quan2' => null, + 'Price' => $price > 0 ? $price : null, + 'Price2' => null, + 'LineDiscAmount' => null, + 'LineItemDesc' => $isBuy ? 'خرید مس مشتری' : 'فروش مس مشتری', + 'UpdateKind' => 0, + ]], + 'Expense' => [], + 'InvTyp' => $invTyp, + 'InvNo' => null, + 'InvDescription' => $isBuy ? 'فروش مس به مشتری' : 'خرید مس از مشتری', + 'InvDate' => $today, + 'InvPayDue' => $today, + 'AccountNumber' => $accountNumber, + 'AccountGuId' => null, + 'GeneralRef' => $orderId > 0 ? ('order:' . $orderId) : null, + 'GuId' => $guid, + 'UpdateKind' => 0, + ]]; + + $row = $existing ?: new Shayegan(); + $row->customer_id = $customerId; + $row->order_id = $orderId > 0 ? $orderId : null; + $row->withdraw_id = null; + $row->amount = $this->scaleAmount((float) ($data['total'] ?? $data['total_price'] ?? ($quan * $price))); + $row->desc = $put[0]['InvDescription']; + $row->type = self::TYPE_TRADE; + $row->date = now(); + $row->endpoint = 'Invoice/Put'; + $row->request_guid = $guid; + $row->data = json_encode($this->client->redactPayload([ + 'Config' => $this->client->buildConfig(), + 'PutObject' => $put, + ]), JSON_UNESCAPED_UNICODE); + $row->sync_status = self::STATUS_PENDING; + $row->sync_log = null; + $row->save(); + + return $this->dispatchPut($row, 'Invoice/Put', $put); + } + + /** + * Rial wallet: Document only (not Invoice). + * direction: 0 deposit, 1 withdraw (same as Zarin/Tahesab wallet cash). + * + * @param array $data + */ + public function recordWalletCash(array $data): ?Shayegan + { + if (! $this->isSyncEnabled()) { + return null; + } + if (! $this->cashMappingReady()) { + Log::info('shayegan.cash_skipped_mapping', ['withdraw_id' => $data['withdraw_id'] ?? null]); + return null; + } + + $customerId = (int) ($data['customer_id'] ?? 0); + $withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null; + $customer = Customer::find($customerId); + $accountNumber = $this->customerAccountNumber($customer); + if ($accountNumber === '') { + return null; + } + + $existing = $this->findExisting(self::TYPE_WALLET_CASH, null, $withdrawId, $customerId); + if ($existing && $this->shouldSkipSend($existing)) { + return $existing; + } + + $isDeposit = (int) ($data['direction'] ?? 0) === 0; + $amount = $this->scaleAmount(abs((float) ($data['value'] ?? 0))); + if ($amount <= 0) { + return null; + } + + $bank = $this->config->getString('shayegan_bank_account', ''); + $guid = $existing && $existing->request_guid + ? (string) $existing->request_guid + : (string) Str::uuid(); + + // Deposit: bank debit + customer credit. Withdraw: customer debit + bank credit. + if ($isDeposit) { + $body = [ + ['AccountNumber' => $bank, 'AmountBed' => $amount, 'AmountBes' => 0, 'RialBed' => $amount, 'RialBes' => 0, 'DocRowDesc' => 'واریز ریالی'], + ['AccountNumber' => $accountNumber, 'AmountBed' => 0, 'AmountBes' => $amount, 'RialBed' => 0, 'RialBes' => $amount, 'DocRowDesc' => 'واریز ریالی مشتری'], + ]; + $desc = 'واریز ریالی'; + } else { + $body = [ + ['AccountNumber' => $accountNumber, 'AmountBed' => $amount, 'AmountBes' => 0, 'RialBed' => $amount, 'RialBes' => 0, 'DocRowDesc' => 'برداشت ریالی مشتری'], + ['AccountNumber' => $bank, 'AmountBed' => 0, 'AmountBes' => $amount, 'RialBed' => 0, 'RialBes' => $amount, 'DocRowDesc' => 'برداشت ریالی'], + ]; + $desc = 'برداشت ریالی'; + } + + $put = [[ + 'Body' => $body, + 'DocDescription' => trim((string) ($data['note'] ?? '')) !== '' ? (string) $data['note'] : $desc, + 'DocDate' => date('Ymd'), + 'DocReference' => $withdrawId ? ('withdraw:' . $withdrawId) : null, + 'GuId' => $guid, + 'UpdateKind' => 0, + ]]; + + $row = $existing ?: new Shayegan(); + $row->customer_id = $customerId; + $row->order_id = null; + $row->withdraw_id = $withdrawId; + $row->amount = $amount; + $row->desc = $desc; + $row->type = self::TYPE_WALLET_CASH; + $row->date = now(); + $row->endpoint = 'Document/Put'; + $row->request_guid = $guid; + $row->data = json_encode($this->client->redactPayload([ + 'Config' => $this->client->buildConfig(), + 'PutObject' => $put, + ]), JSON_UNESCAPED_UNICODE); + $row->sync_status = self::STATUS_PENDING; + $row->sync_log = null; + $row->save(); + + return $this->dispatchPut($row, 'Document/Put', $put); + } + + /** + * Product / metal wallet movement — never invent warehouse unless mode says so. + * direction: 1 deposit, 0 withdraw (walletGold convention). + * + * @param array $data + */ + public function recordWalletGold(array $data): ?Shayegan + { + if (! $this->isSyncEnabled()) { + return null; + } + + $mode = $this->metalWalletMode(); + if ($mode === 'skip') { + return null; + } + + $customerId = (int) ($data['customer_id'] ?? 0); + $withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null; + $customer = Customer::find($customerId); + $accountNumber = $this->customerAccountNumber($customer); + if ($accountNumber === '') { + return null; + } + + $existing = $this->findExisting(self::TYPE_WALLET_GOLD, null, $withdrawId, $customerId); + if ($existing && $this->shouldSkipSend($existing)) { + return $existing; + } + + $isDeposit = (int) ($data['direction'] ?? 1) === 1; + $quan = $this->scaleWeight(abs((float) ($data['value'] ?? 0))); + if ($quan <= 0) { + return null; + } + + $guid = $existing && $existing->request_guid + ? (string) $existing->request_guid + : (string) Str::uuid(); + + if ($mode === 'receipt_havale') { + if (! $this->tradeMappingReady()) { + return null; + } + $invTyp = $isDeposit ? self::INV_RECEIPT : self::INV_HAVALE; + $put = [[ + 'Body' => [[ + 'STNumber' => $this->config->getString('shayegan_stock_number', ''), + 'ItemNumber' => $this->config->getString('shayegan_item_copper', ''), + 'Quan' => $quan, + 'Price' => null, + 'LineItemDesc' => $isDeposit ? 'واریز محصول' : 'برداشت محصول', + 'UpdateKind' => 0, + ]], + 'Expense' => [], + 'InvTyp' => $invTyp, + 'InvNo' => null, + 'InvDescription' => $isDeposit ? 'رسید انبار (واریز محصول)' : 'حواله (برداشت محصول)', + 'InvDate' => date('Ymd'), + 'AccountNumber' => $accountNumber, + 'GeneralRef' => $withdrawId ? ('withdraw:' . $withdrawId) : null, + 'GuId' => $guid, + 'UpdateKind' => 0, + ]]; + $endpoint = 'Invoice/Put'; + $desc = $put[0]['InvDescription']; + } else { + // document mode: accounting note only — no stock invoice + $bank = $this->config->getString('shayegan_bank_account', ''); + if ($bank === '') { + Log::info('shayegan.gold_document_needs_bank_or_switch_mode'); + return null; + } + // Placeholder balanced zero-amount skip — without operator chart of accounts for metal, + // document mode requires explicit counterpart accounts; refuse rather than invent. + Log::info('shayegan.gold_document_needs_account_map', [ + 'hint' => 'Provide metal counterpart accounts or use receipt_havale after confirming warehouse semantics.', + ]); + + return null; + } + + $row = $existing ?: new Shayegan(); + $row->customer_id = $customerId; + $row->order_id = null; + $row->withdraw_id = $withdrawId; + $row->amount = $quan; + $row->desc = $desc; + $row->type = self::TYPE_WALLET_GOLD; + $row->date = now(); + $row->endpoint = $endpoint; + $row->request_guid = $guid; + $row->data = json_encode($this->client->redactPayload([ + 'Config' => $this->client->buildConfig(), + 'PutObject' => $put, + ]), JSON_UNESCAPED_UNICODE); + $row->sync_status = self::STATUS_PENDING; + $row->sync_log = null; + $row->save(); + + return $this->dispatchPut($row, $endpoint, $put); + } + + /** + * @param array> $putObject + */ + private function dispatchPut(Shayegan $row, string $endpoint, array $putObject): Shayegan + { + $result = $this->client->put($endpoint, $putObject); + + if (! empty($result['timeout'])) { + $row->sync_status = self::STATUS_TIMEOUT; + $row->sync_log = (string) ($result['message'] ?? 'timeout'); + $row->res = json_encode(['timeout' => true, 'message' => $result['message'] ?? null], JSON_UNESCAPED_UNICODE); + $row->save(); + + return $row; + } + + if (! empty($result['ok'])) { + $row->sync_status = self::STATUS_SUCCESS; + $row->sync_log = null; + $row->shayegan_number = $result['number'] ?? null; + $row->shayegan_guid = $result['guid'] ?? null; + $row->res = json_encode($result['body'] ?? [], JSON_UNESCAPED_UNICODE); + $row->save(); + + return $row; + } + + $row->sync_status = self::STATUS_FAILED; + $row->sync_log = (string) ($result['message'] ?? 'failed'); + $row->res = json_encode($result['body'] ?? ['message' => $result['message'] ?? null], JSON_UNESCAPED_UNICODE); + $row->save(); + + return $row; + } + + private function shouldSkipSend(Shayegan $row): bool + { + $status = (string) $row->sync_status; + if ($status === self::STATUS_SUCCESS) { + return true; + } + // Never auto-resend after timeout without manual verification. + if ($status === self::STATUS_TIMEOUT) { + return true; + } + + return false; + } + + private function findExisting(string $type, ?int $orderId, ?int $withdrawId, int $customerId): ?Shayegan + { + if (! Schema::hasTable('shayegan')) { + return null; + } + + $q = Shayegan::query()->where('type', $type)->where('customer_id', $customerId); + if ($orderId) { + $q->where('order_id', $orderId); + } + if ($withdrawId) { + $q->where('withdraw_id', $withdrawId); + } + if (! $orderId && ! $withdrawId) { + return null; + } + + return $q->orderByDesc('id')->first(); + } + + private function customerAccountNumber(?Customer $customer): string + { + if (! $customer) { + return ''; + } + + return trim((string) ($customer->moshtari_code ?? '')); + } + + private function scaleAmount(float $amount): float + { + $scale = (float) $this->config->getString('shayegan_amount_scale', '1'); + if ($scale <= 0) { + $scale = 1.0; + } + + return round($amount * $scale, 4); + } + + private function scaleWeight(float $weight): float + { + $scale = (float) $this->config->getString('shayegan_weight_scale', '1'); + if ($scale <= 0) { + $scale = 1.0; + } + + return round($weight * $scale, 6); + } + + private function markLocalFailed( + ?Shayegan $existing, + int $customerId, + ?int $orderId, + ?int $withdrawId, + string $type, + string $message, + $amount + ): Shayegan { + $row = $existing ?: new Shayegan(); + $row->customer_id = $customerId; + $row->order_id = $orderId; + $row->withdraw_id = $withdrawId; + $row->amount = $amount; + $row->type = $type; + $row->date = now(); + $row->sync_status = self::STATUS_FAILED; + $row->sync_log = $message; + $row->save(); + + return $row; + } +} diff --git a/database/migrations/2026_10_05_000001_create_shayegan_table.php b/database/migrations/2026_10_05_000001_create_shayegan_table.php new file mode 100644 index 0000000..61d56e4 --- /dev/null +++ b/database/migrations/2026_10_05_000001_create_shayegan_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('customer_id')->nullable()->index(); + $table->unsignedBigInteger('order_id')->nullable()->index(); + $table->unsignedBigInteger('withdraw_id')->nullable()->index(); + $table->decimal('amount', 18, 4)->nullable(); + $table->string('desc')->nullable(); + $table->string('type', 32)->nullable()->index(); + $table->timestamp('date')->nullable(); + $table->string('endpoint', 64)->nullable(); + $table->string('request_guid', 64)->nullable()->index(); + $table->string('shayegan_number')->nullable()->index(); + $table->string('shayegan_guid')->nullable()->index(); + $table->longText('data')->nullable(); + $table->longText('res')->nullable(); + // pending|success|failed|timeout — timeout must not auto-resend + $table->string('sync_status', 20)->default('pending')->index(); + $table->text('sync_log')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('shayegan'); + } +}; diff --git a/database/migrations/2026_10_05_000002_add_shayegan_accounting_settings.php b/database/migrations/2026_10_05_000002_add_shayegan_accounting_settings.php new file mode 100644 index 0000000..21dec52 --- /dev/null +++ b/database/migrations/2026_10_05_000002_add_shayegan_accounting_settings.php @@ -0,0 +1,168 @@ + [ + 'value' => 'http://2.180.4.172:2030', + 'title_fa' => 'آدرس پایه وب‌سرویس شایگان', + 'description' => 'بدون /api در انتها. عمومی دفتر: http://2.180.4.172:2030 — داخلی LAN: http://192.168.109.222:2030 — اگر 404 شد /WsServer اضافه کنید. نام SQL را اینجا نگذارید.', + 'type' => 'text', + ], + 'shayegan_sql_server' => [ + 'value' => 'P-SERVER', + 'title_fa' => 'نام SQL Server شایگان (Config.Server)', + 'description' => 'Config.Server — فقط برای بدنهٔ وب‌سرویس، نه آدرس HTTP. (نمونهٔ بورس مس: P-SERVER)', + 'type' => 'text', + ], + 'shayegan_database' => [ + 'value' => 'CYPRSD04', + 'title_fa' => 'نام دیتابیس حسابگر (Config.DataBaseName)', + 'description' => 'نمونهٔ عملیاتی بورس مس: CYPRSD04 — برای تست ثبت از دیتابیس آزمایشی استفاده کنید.', + 'type' => 'text', + ], + 'shayegan_db_user' => [ + 'value' => 'sa', + 'title_fa' => 'کاربر SQL Server شایگان', + 'description' => 'Config.DBUserName — فقط backend؛ در پاسخ API ماسک می‌شود.', + 'type' => 'text', + ], + 'shayegan_db_password' => [ + 'value' => '', + 'title_fa' => 'رمز SQL Server شایگان', + 'description' => 'Config.DBPassword — فقط backend؛ در لاگ و frontend برنمی‌گردد.', + 'type' => 'password', + ], + 'shayegan_auth_user' => [ + 'value' => '', + 'title_fa' => 'AuthUser وب‌سرویس شایگان', + 'description' => 'خالی = بدون تایید هویت، یا کاربر حسابگر/شایگان طبق حالت نصب.', + 'type' => 'text', + ], + 'shayegan_auth_password' => [ + 'value' => '', + 'title_fa' => 'AuthPassword وب‌سرویس شایگان', + 'description' => 'فقط backend؛ در لاگ و frontend برنمی‌گردد.', + 'type' => 'password', + ], + 'shayegan_language' => [ + 'value' => '3', + 'title_fa' => 'Language شایگان', + 'description' => 'Config.Language — نمونه مستندات معمولاً 3 است.', + 'type' => 'number', + ], + 'shayegan_timeout_seconds' => [ + 'value' => '30', + 'title_fa' => 'Timeout HTTP شایگان (ثانیه)', + 'description' => 'پس از timeout وضعیت timeout ذخیره می‌شود و ارسال خودکار تکرار نمی‌شود.', + 'type' => 'number', + ], + 'shayegan_item_copper' => [ + 'value' => '', + 'title_fa' => 'کد کالای مس در حسابگر (ItemNumber)', + 'description' => 'برای فاکتور خرید/فروش مس.', + 'type' => 'text', + ], + 'shayegan_stock_number' => [ + 'value' => '', + 'title_fa' => 'شماره انبار مس (STNumber)', + 'description' => 'انبار ردیف فاکتور خرید/فروش.', + 'type' => 'text', + ], + 'shayegan_bank_account' => [ + 'value' => '', + 'title_fa' => 'شماره حساب بانک/صندوق ریالی', + 'description' => 'طرف حساب بانک در Document واریز/برداشت ریالی.', + 'type' => 'text', + ], + 'shayegan_moin_customer' => [ + 'value' => '', + 'title_fa' => 'شماره معین مشتریان (MoinNumber)', + 'description' => 'برای Account/Put هنگام ساخت حساب مشتری در حسابگر.', + 'type' => 'text', + ], + 'shayegan_amount_scale' => [ + 'value' => '1', + 'title_fa' => 'ضریب مبلغ به شایگان', + 'description' => 'مبلغ پنل ریال است. اگر حسابگر تومان است 0.1 بگذارید؛ اگر ریال است 1.', + 'type' => 'text', + ], + 'shayegan_weight_scale' => [ + 'value' => '1', + 'title_fa' => 'ضریب وزن به Quan شایگان', + 'description' => 'وزن پنل را به واحد کالای حسابگر تبدیل می‌کند (مثلاً گرم→کیلو = 0.001).', + 'type' => 'text', + ], + 'shayegan_metal_wallet_mode' => [ + 'value' => 'skip', + 'title_fa' => 'حالت واریز/برداشت محصول به شایگان', + 'description' => 'skip=ارسال نشود | document=فقط سند حسابداری | receipt_havale=رسید(5)/حواله(4). تغییر کیف را ورود/خروج انبار فرض نکنید مگر صریح.', + 'type' => 'text', + ], + ]; + + public function up(): void + { + $hasMenu = Schema::hasColumn('settings', 'menu'); + + foreach (self::KEYS as $key => $meta) { + $exists = DB::table('settings')->where('key', $key)->exists(); + if (! $exists) { + $row = [ + 'key' => $key, + 'value' => $meta['value'], + 'title_fa' => $meta['title_fa'], + 'can_edit' => 0, + 'is_active' => 1, + 'description' => $meta['description'], + 'type' => $meta['type'], + ]; + if ($hasMenu) { + $row['menu'] = 'accounting_shayegan'; + } + DB::table('settings')->insert($row); + continue; + } + + $update = [ + 'title_fa' => $meta['title_fa'], + 'description' => $meta['description'], + 'type' => $meta['type'], + 'can_edit' => 0, + 'is_active' => 1, + ]; + if ($hasMenu) { + $update['menu'] = 'accounting_shayegan'; + } + DB::table('settings')->where('key', $key)->update($update); + } + + $provider = DB::table('settings')->where('key', 'accounting_provider')->first(); + if ($provider) { + $desc = (string) ($provider->description ?? ''); + if (strpos($desc, 'shayegan') === false) { + if (strpos($desc, 'none,pargar,tahesab,zarin') !== false) { + $newDesc = str_replace('none,pargar,tahesab,zarin', 'none,pargar,tahesab,zarin,shayegan', $desc); + } else { + $newDesc = trim($desc) === '' ? 'none,pargar,tahesab,zarin,shayegan' : ($desc . ',shayegan'); + } + DB::table('settings')->where('key', 'accounting_provider')->update([ + 'description' => $newDesc, + ]); + } + } + } + + public function down(): void + { + DB::table('settings')->whereIn('key', array_keys(self::KEYS))->delete(); + } +}; diff --git a/database/migrations/2026_10_05_000003_boursecu_accounting_provider_shayegan.php b/database/migrations/2026_10_05_000003_boursecu_accounting_provider_shayegan.php new file mode 100644 index 0000000..94d80a5 --- /dev/null +++ b/database/migrations/2026_10_05_000003_boursecu_accounting_provider_shayegan.php @@ -0,0 +1,44 @@ +hasTable('settings')) { + return; + } + + DB::table('settings') + ->where('key', 'accounting_provider') + ->where(function ($q) { + $q->whereNull('value') + ->orWhere('value', '') + ->orWhere('value', 'none'); + }) + ->update(['value' => 'shayegan']); + + $row = DB::table('settings')->where('key', 'accounting_provider')->first(); + if (! $row) { + DB::table('settings')->insert([ + 'key' => 'accounting_provider', + 'value' => 'shayegan', + 'title_fa' => 'سیستم حسابداری', + 'can_edit' => 0, + 'is_active' => 1, + 'description' => 'boursecu: shayegan (ثابت در کد)', + 'type' => 'select', + ]); + } + } + + public function down(): void + { + // عمداً برنمی‌گردانیم — بورس مس همیشه شایگان است. + } +}; diff --git a/docs/shayegan/.gitignore b/docs/shayegan/.gitignore new file mode 100644 index 0000000..4ddb8ad --- /dev/null +++ b/docs/shayegan/.gitignore @@ -0,0 +1,7 @@ +shayegan.pdf +Sample_Shygun.postman_collection.json +pdf_*.txt +put_endpoints.txt +node_modules/ +package.json +package-lock.json diff --git a/docs/shayegan/API_NOTES.md b/docs/shayegan/API_NOTES.md new file mode 100644 index 0000000..76a780a --- /dev/null +++ b/docs/shayegan/API_NOTES.md @@ -0,0 +1,84 @@ +# Shayegan webservice notes (PDF + Sample_Shygun Postman) + +Do not store real credentials in this file or in Git. + +## Known for boursecu (operator, 2026-10-05) + +Store panel (`app.boursemes.com`) is the **Laravel API**, not Shayegan: +- Shop domain: `app.boursemes.com` +- `node_server`: `https://app.boursemes.com/server` +- Listed LAN IP on that panel: `192.168.70.189` — different from Shayegan host; do not use as WsServer unless confirmed. + +Shayegan webservice hosts (operator): +- Public/static (Mashhad office): `2.180.4.172` +- Internal LAN: `192.168.109.222` +- Port: `2030` +- Candidate `shayegan_base_url` (no `/api` suffix): + - From internet / remote app server: `http://2.180.4.172:2030` + - From same LAN as حسابگر: `http://192.168.109.222:2030` + - If 404, retry with `/WsServer` appended (Postman samples). + +SQL Config for Shayegan Put/Get (inside JSON `Config`, not HTTP host): +- `Server`: `P-SERVER` +- `DataBaseName`: `CYPRSD04` +- `DBUserName`: `sa` +- Password: set only in DB `settings.shayegan_db_password` (never commit). + +Still useful from operator: Auth*, ItemNumber, STNumber, bank account, amount/weight scales, metal wallet mode. + +## Base URL (PDF) + +`http://{host}[:port]{optionalBasePath}/api/{Endpoint}` + +PDF example: `http://192.168.1.1/api/Document/Put` +Postman often inserts `/WsServer` or `/WsServer-3.13` before `/api`. +Operator port hint: `2030`. SQL Server name is **not** the HTTP host. + +## Request shape + +Every call body includes `Config`. +Get calls also include `Domain` (+ `RowStart`/`RowCount` query). +Put calls include `PutObject` array at the **same level** as `Config` (raw HTTP / Postman). PDF C# wrapper is named `Put`. + +### Config fields + +`Server`, `AllowRowSecurity`, `Level`, `DBUserName`, `DBPassword`, `DataBaseName`, `AuthUser`, `AuthPassword`, `Language` +Auth: Shayegan cloud / Hesabgar / none (empty Auth*). + +## Endpoints used + +| Op | Method path | +|----|-------------| +| Health (read) | `POST .../api/Account/Get?RowStart=0&RowCount=1` | +| Trade | `POST .../api/Invoice/Put` | +| Rial ledger | `POST .../api/Document/Put` | +| Customer account | `POST .../api/Account/Put` | + +## Put response (check even on HTTP 200) + +```json +{ + "Result": [{ "ErrorMessage": "", "Number": "6572", "GuId": "..." }], + "ErrorMessage": "", + "TokenString": "" +} +``` + +Treat non-empty top-level or per-row `ErrorMessage` as failure. + +## InvTyp (company) + +1 تولید | 2 فروش | 3 خرید | 4 حواله | 5 رسید انبار | 6 برگشت فروش | 7 برگشت خرید | 8/9 تعدیل | 10 انتقال انبار | 11/12 سفارش | 13 پیش‌فاکتور + +Customer **buy** copper → company **فروش** → `InvTyp=2` +Customer **sell** copper → company **خرید** → `InvTyp=3` + +## Document body (PDF) + +Prefer `AmountBed` / `AmountBes` (also `RialBed` / `RialBes` exist). +Header: `DocDescription`, `DocDate` (`yyyyMMdd`), `DocReference`, `GuId`. + +## Invoice required (PDF) + +`AccountNumber`, `InvTyp`, `InvDate`; line: item + `STNumber` + `Quan` + `Price`. +Ignore Postman keys with trailing spaces — PDF field names are authoritative. diff --git a/routes/admin.php b/routes/admin.php index dc8bfa4..6bbdab7 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -19,6 +19,7 @@ use App\Http\Controllers\Admin\ProductController; use App\Http\Controllers\Admin\TahesabController; use App\Http\Controllers\Admin\ZarinController; use App\Http\Controllers\Admin\PargarController; +use App\Http\Controllers\Admin\ShayeganController; use App\Http\Controllers\Admin\FileController; use App\Http\Controllers\Site\OfferCodesController; use App\Http\Controllers\Admin\SettingsController; @@ -288,6 +289,11 @@ Route::middleware('auth:user')->group(function (){ Route::post('customer/{id}/zarin/sync-wallet', [ZarinController::class, 'syncCustomerWallet']); Route::post('customer/{id}/zarin/preview-wallet', [ZarinController::class, 'previewCustomerWallet']); + Route::get('shayegan/health', [ShayeganController::class, 'healthCheck']); + Route::post('shayegan/table', [ShayeganController::class, 'table']); + Route::get('shayegan/{id}', [ShayeganController::class, 'show']); + Route::post('shayegan/{id}/retry', [ShayeganController::class, 'retrySanad']); + Route::post('pargar/table', [PargarController::class, 'table']); Route::get('pargar/customer-sync/status', [PargarController::class, 'customerSyncStatus']); Route::get('pargar/stuck-outbound', [PargarController::class, 'stuckOutbound']); diff --git a/tests/Unit/ShayeganClientErrorMessageTest.php b/tests/Unit/ShayeganClientErrorMessageTest.php new file mode 100644 index 0000000..a50b5bd --- /dev/null +++ b/tests/Unit/ShayeganClientErrorMessageTest.php @@ -0,0 +1,57 @@ +clientWithoutConstruct(); + $method = new ReflectionMethod(ShayeganClient::class, 'extractErrorMessage'); + $method->setAccessible(true); + + $msg = $method->invoke($client, [ + 'ErrorMessage' => 'missing account', + 'Result' => [['ErrorMessage' => '', 'Number' => '1']], + ]); + + $this->assertSame('missing account', $msg); + } + + public function test_extract_error_message_from_result_row(): void + { + $client = $this->clientWithoutConstruct(); + $method = new ReflectionMethod(ShayeganClient::class, 'extractErrorMessage'); + $method->setAccessible(true); + + $msg = $method->invoke($client, [ + 'ErrorMessage' => '', + 'Result' => [['ErrorMessage' => 'row failed', 'Number' => null]], + ]); + + $this->assertSame('row failed', $msg); + } + + public function test_empty_error_message_is_success_signal(): void + { + $client = $this->clientWithoutConstruct(); + $method = new ReflectionMethod(ShayeganClient::class, 'extractErrorMessage'); + $method->setAccessible(true); + + $msg = $method->invoke($client, [ + 'ErrorMessage' => '', + 'Result' => [['ErrorMessage' => '', 'Number' => '12', 'GuId' => 'abc']], + ]); + + $this->assertSame('', $msg); + } + + private function clientWithoutConstruct(): ShayeganClient + { + return (new \ReflectionClass(ShayeganClient::class))->newInstanceWithoutConstructor(); + } +}