feat(accounting): add Shayegan as fixed boursecu provider

Wire outbound Invoice/Document sync after confirmed ops, store sync status with timeout lock, and keep SQL credentials out of frontend responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 17:12:43 +03:30
co-authored by Cursor
parent 698fbed805
commit a42261c06e
20 changed files with 1485 additions and 18 deletions
@@ -3,6 +3,7 @@
فرمت هر خط: فرمت هر خط:
- YYYY-MM-DD | موضوع | مسیر فایل(ها) | نکتهٔ ۱ خطی - 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-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 | خطای «محدودیت فیلد قیمت» در گیفت‌کارت | `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) تا مبالغ ≥۱۰۰ میلیون ریال جا شود - 2026-09-08 | widen مبلغ گیفت‌کارت | `migrations/2026_09_08_000001_widen_giftcard_amount_columns.php` + `deploy_safe_schema.sql` | `amount`/`total` → DECIMAL(20,4) تا مبالغ ≥۱۰۰ میلیون ریال جا شود
@@ -81,7 +81,7 @@ class SettingsController extends Controller
->orderBy('key') ->orderBy('key')
->get(); ->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; $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; $setting->description = $request->description;
if ($request->has('is_active')) { if ($request->has('is_active')) {
$setting->is_active = filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN) ? 1 : 0; $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); $overlay = app(SuperAdminProgramSettingsOverlay::class);
$saMap = $overlay->catalogMap(); $saMap = $overlay->catalogMap();
if ($saMap === null) { if ($saMap === null) {
return $localRows; return $this->maskSecretSettingValues($localRows);
} }
$localByKey = []; $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 private function isEditableCanEdit(mixed $canEdit): bool
@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Shayegan;
use App\Services\ShayeganClient;
use App\Services\ShayeganSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
class ShayeganController extends Controller
{
/** @var ShayeganSyncService */
private $sync;
/** @var ShayeganClient */
private $client;
public function __construct(ShayeganSyncService $sync, ShayeganClient $client)
{
$this->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<string, mixed> $data
*/
public function storeTrade(array $data)
{
return $this->sync->recordTrade($data);
}
/**
* @param array<string, mixed> $data
*/
public function wallet(array $data)
{
return $this->sync->recordWalletCash($data);
}
/**
* @param array<string, mixed> $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;
}
}
@@ -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 { try {
app(PargarSyncService::class)->queueWalletDocument( app(PargarSyncService::class)->queueWalletDocument(
$customer, $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 { try {
app(PargarSyncService::class)->queueWalletDocument( app(PargarSyncService::class)->queueWalletDocument(
$customer, $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 { try {
// فقط محصول متصل به کیف اصلی → سند طلای پرگار // فقط محصول متصل به کیف اصلی → سند طلای پرگار
app(PargarSyncService::class)->queueWalletDocument( app(PargarSyncService::class)->queueWalletDocument(
@@ -445,6 +445,14 @@ class CartController extends Controller
report($e); report($e);
} }
} }
if ($config->isShayeganAccounting()) {
try {
app(\App\Http\Controllers\Admin\ShayeganController::class)->storeTrade($payload);
} catch (\Throwable $e) {
report($e);
}
}
} }
if ($config->isPargarAccounting()) { if ($config->isPargarAccounting()) {
@@ -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 $companyName = $this->Config('name_fa'); // Company name from configuration
$message = "کاربر $userName با شماره موبایل $userMobile درخواست برداشت مبلغ " . number_format($withdrawAmount) . " ریال را دارد. با تشکر، $companyName"; $message = "کاربر $userName با شماره موبایل $userMobile درخواست برداشت مبلغ " . number_format($withdrawAmount) . " ریال را دارد. با تشکر، $companyName";
User::where('send_sms', 1)->get()->each(function ($admin) use ($message) { User::where('send_sms', 1)->get()->each(function ($admin) use ($message) {
@@ -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'])) { if (isset($data['order'])) {
try { try {
app(\App\Services\ColleagueAccountingService::class)->recordCoveredTrade($data); app(\App\Services\ColleagueAccountingService::class)->recordCoveredTrade($data);
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Shayegan extends Model
{
protected $table = 'shayegan';
protected $guarded = [];
protected $casts = [
'date' => 'datetime',
'amount' => 'float',
];
}
+8
View File
@@ -59,5 +59,13 @@ class AccountingWalletPush
report($e); report($e);
} }
} }
if ($config->isShayeganAccounting()) {
try {
app(\App\Http\Controllers\Admin\ShayeganController::class)->walletGold($payload);
} catch (\Throwable $e) {
report($e);
}
}
} }
} }
+11 -14
View File
@@ -1261,6 +1261,7 @@ class ConfigService
public const ACCOUNTING_TAHESAB = 'tahesab'; public const ACCOUNTING_TAHESAB = 'tahesab';
public const ACCOUNTING_PARGAR = 'pargar'; public const ACCOUNTING_PARGAR = 'pargar';
public const ACCOUNTING_ZARIN = 'zarin'; public const ACCOUNTING_ZARIN = 'zarin';
public const ACCOUNTING_SHAYEGAN = 'shayegan';
public function isConditionalOrderEnabled(): bool public function isConditionalOrderEnabled(): bool
{ {
@@ -1311,22 +1312,13 @@ class ConfigService
return max(0, $this->getInt('fee_conditional_order', 0)); 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 public function accountingProvider(): string
{ {
$provider = strtolower(trim($this->getString('accounting_provider', ''))); return self::ACCOUNTING_SHAYEGAN;
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;
} }
public function isTahesabAccounting(): bool public function isTahesabAccounting(): bool
@@ -1344,6 +1336,11 @@ class ConfigService
return $this->accountingProvider() === self::ACCOUNTING_ZARIN; 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. * Hostname for WebOTP SMS (@domain #code) and other public links.
* Prefer settings.app_url (دامنهٔ اپ کاربر) over the API request host — * Prefer settings.app_url (دامنهٔ اپ کاربر) over the API request host —
@@ -125,5 +125,13 @@ class WalletDepositCreditor
report($e); report($e);
} }
} }
if ($this->config->isShayeganAccounting()) {
try {
app(\App\Http\Controllers\Admin\ShayeganController::class)->wallet($payload);
} catch (\Throwable $e) {
report($e);
}
}
} }
} }
+266
View File
@@ -0,0 +1,266 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* HTTP client for Shayegan Cyber Account webservice (REST JSON).
* Contract from vendor PDF: Config + Domain|PutObject; always inspect ErrorMessage.
*/
class ShayeganClient
{
/** @var ConfigService */
private $config;
public function __construct(ConfigService $config)
{
$this->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<string, mixed>
*/
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<string, mixed> $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<int, array<string, mixed>> $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<string, mixed> $payload
* @param array<string, int|string> $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<string, int|string> $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<string, mixed> $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<string, mixed> $body
* @return array<string, mixed>|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<string, mixed> $payload
* @return array<string, mixed>
*/
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;
}
}
+475
View File
@@ -0,0 +1,475 @@
<?php
namespace App\Services;
use App\Models\Customer;
use App\Models\Shayegan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* Outbound Shayegan sync — does not mutate wallets.
* Timeout rows stay timeout and are never auto-retried.
*/
class ShayeganSyncService
{
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public const STATUS_TIMEOUT = 'timeout';
public const TYPE_TRADE = 'trade';
public const TYPE_WALLET_CASH = 'wallet_cash';
public const TYPE_WALLET_GOLD = 'wallet_gold';
public const TYPE_CUSTOMER = 'customer';
/** InvTyp from Shayegan PDF (company view). */
public const INV_SALE = 2;
public const INV_PURCHASE = 3;
public const INV_HAVALE = 4;
public const INV_RECEIPT = 5;
/** @var ConfigService */
private $config;
/** @var ShayeganClient */
private $client;
public function __construct(ConfigService $config, ShayeganClient $client)
{
$this->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<string, mixed> $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<string, mixed> $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<string, mixed> $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<int, array<string, mixed>> $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;
}
}
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('shayegan')) {
Schema::create('shayegan', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,168 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* تنظیمات اتصال شایگان — value ردیف موجود را UPDATE نمی‌کند.
* رمزها را در Git نگذار؛ بعد از migrate در DB پر کن.
*/
return new class extends Migration
{
private const KEYS = [
'shayegan_base_url' => [
'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();
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* بورس مس: مقدار accounting_provider فقط وقتی خالی/none است → shayegan (valueهای دیگر دست نمی‌خورد).
*/
return new class extends Migration
{
public function up(): void
{
if (! DB::getSchemaBuilder()->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
{
// عمداً برنمی‌گردانیم — بورس مس همیشه شایگان است.
}
};
+7
View File
@@ -0,0 +1,7 @@
shayegan.pdf
Sample_Shygun.postman_collection.json
pdf_*.txt
put_endpoints.txt
node_modules/
package.json
package-lock.json
+84
View File
@@ -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.
+6
View File
@@ -19,6 +19,7 @@ use App\Http\Controllers\Admin\ProductController;
use App\Http\Controllers\Admin\TahesabController; use App\Http\Controllers\Admin\TahesabController;
use App\Http\Controllers\Admin\ZarinController; use App\Http\Controllers\Admin\ZarinController;
use App\Http\Controllers\Admin\PargarController; use App\Http\Controllers\Admin\PargarController;
use App\Http\Controllers\Admin\ShayeganController;
use App\Http\Controllers\Admin\FileController; use App\Http\Controllers\Admin\FileController;
use App\Http\Controllers\Site\OfferCodesController; use App\Http\Controllers\Site\OfferCodesController;
use App\Http\Controllers\Admin\SettingsController; 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/sync-wallet', [ZarinController::class, 'syncCustomerWallet']);
Route::post('customer/{id}/zarin/preview-wallet', [ZarinController::class, 'previewCustomerWallet']); 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::post('pargar/table', [PargarController::class, 'table']);
Route::get('pargar/customer-sync/status', [PargarController::class, 'customerSyncStatus']); Route::get('pargar/customer-sync/status', [PargarController::class, 'customerSyncStatus']);
Route::get('pargar/stuck-outbound', [PargarController::class, 'stuckOutbound']); Route::get('pargar/stuck-outbound', [PargarController::class, 'stuckOutbound']);
@@ -0,0 +1,57 @@
<?php
namespace Tests\Unit;
use App\Services\ShayeganClient;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
class ShayeganClientErrorMessageTest extends TestCase
{
public function test_extract_error_message_from_top_level(): void
{
$client = $this->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();
}
}