2026-10-05 17:12:43 +03:30
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use App\Models\Customer;
|
2026-10-28 16:51:47 +03:30
|
|
|
use App\Models\Product;
|
2026-10-05 17:12:43 +03:30
|
|
|
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
|
|
|
|
|
{
|
2026-10-28 16:51:47 +03:30
|
|
|
return $this->config->getString('shayegan_stock_number', '') !== '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function customerMappingReady(): bool
|
|
|
|
|
{
|
|
|
|
|
return trim($this->config->getString('shayegan_moin_customer', '')) !== '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function registerOnCreate(Customer $customer): void
|
|
|
|
|
{
|
|
|
|
|
if (! empty($customer->moshtari_code) || ! $this->isSyncEnabled() || ! $this->customerMappingReady()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$customerId = (int) $customer->id;
|
|
|
|
|
|
|
|
|
|
dispatch(function () use ($customerId) {
|
|
|
|
|
$service = app(self::class);
|
|
|
|
|
$customer = Customer::find($customerId);
|
|
|
|
|
if ($customer === null || ! empty($customer->moshtari_code) || ! $service->isSyncEnabled()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$result = $service->registerCustomer($customer);
|
|
|
|
|
if (! ($result['ok'] ?? false)) {
|
|
|
|
|
Log::warning('shayegan.auto_register_on_create_failed', [
|
|
|
|
|
'customer_id' => $customerId,
|
|
|
|
|
'message' => $result['message'] ?? null,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
Log::warning('shayegan.auto_register_on_create_threw', [
|
|
|
|
|
'customer_id' => $customerId,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
})->afterResponse();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{ok:bool,message:string,moshtari_code?:string}
|
|
|
|
|
*/
|
|
|
|
|
public function registerCustomer(Customer $customer): array
|
|
|
|
|
{
|
|
|
|
|
$customer->refresh();
|
|
|
|
|
|
|
|
|
|
if (! empty($customer->moshtari_code)) {
|
|
|
|
|
return [
|
|
|
|
|
'ok' => true,
|
|
|
|
|
'message' => 'مشتری قبلاً در شایگان ثبت شده است',
|
|
|
|
|
'moshtari_code' => (string) $customer->moshtari_code,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! $this->isSyncEnabled()) {
|
|
|
|
|
return ['ok' => false, 'message' => 'حسابداری شایگان فعال نیست یا تنظیمات اتصال ناقص است'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! $this->customerMappingReady()) {
|
|
|
|
|
return ['ok' => false, 'message' => 'شماره معین مشتریان (shayegan_moin_customer) در تنظیمات خالی است'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$moinRaw = trim($this->config->getString('shayegan_moin_customer', ''));
|
|
|
|
|
$name = trim((string) ($customer->name ?? ''));
|
|
|
|
|
if ($name === '') {
|
|
|
|
|
$name = 'مشتری ' . $customer->id;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-02 13:05:53 +03:30
|
|
|
// مشتری تفصیلی شناور است (Job/Put)، نه حساب زیر معین. شماره = معین + کد کاربری.
|
2026-11-02 11:49:47 +03:30
|
|
|
$requestedAccountNumber = $moinRaw . ((string) (int) $customer->id);
|
2026-11-02 10:57:57 +03:30
|
|
|
|
2026-10-28 16:51:47 +03:30
|
|
|
$guid = (string) Str::uuid();
|
|
|
|
|
$put = [[
|
2026-11-02 13:05:53 +03:30
|
|
|
'JobNumber' => $requestedAccountNumber,
|
|
|
|
|
'JobName' => mb_substr($name, 0, 200),
|
2026-10-28 16:51:47 +03:30
|
|
|
'Mobile' => trim((string) ($customer->mobile ?? '')) ?: null,
|
|
|
|
|
'mellicode' => trim((string) ($customer->n_code ?? '')) ?: null,
|
|
|
|
|
'GuId' => $guid,
|
2026-11-02 11:35:33 +03:30
|
|
|
'UpdateKind' => 0,
|
2026-10-28 16:51:47 +03:30
|
|
|
]];
|
2026-11-02 13:05:53 +03:30
|
|
|
$jobGroup = trim($this->config->getString('shayegan_job_group', ''));
|
|
|
|
|
if ($jobGroup !== '') {
|
|
|
|
|
$put[0]['JobGroupNumber'] = $jobGroup;
|
|
|
|
|
}
|
2026-10-28 16:51:47 +03:30
|
|
|
|
2026-11-02 13:05:53 +03:30
|
|
|
$result = $this->client->put('Job/Put', $put);
|
2026-10-28 16:51:47 +03:30
|
|
|
$accountNumber = trim((string) ($result['number'] ?? ''));
|
2026-11-02 11:21:36 +03:30
|
|
|
$exchange = $this->client->debugExchange($result);
|
2026-10-28 16:51:47 +03:30
|
|
|
|
|
|
|
|
if (! empty($result['timeout'])) {
|
2026-11-02 11:21:36 +03:30
|
|
|
return array_merge([
|
|
|
|
|
'ok' => false,
|
|
|
|
|
'message' => (string) ($result['message'] ?? 'timeout'),
|
|
|
|
|
], $exchange);
|
2026-10-28 16:51:47 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($result['ok']) || $accountNumber === '') {
|
2026-11-02 11:21:36 +03:30
|
|
|
return array_merge([
|
2026-10-28 16:51:47 +03:30
|
|
|
'ok' => false,
|
|
|
|
|
'message' => (string) ($result['message'] ?? 'ثبت حساب مشتری در شایگان ناموفق بود'),
|
2026-11-02 11:21:36 +03:30
|
|
|
], $exchange);
|
2026-10-28 16:51:47 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$customer->moshtari_code = $accountNumber;
|
|
|
|
|
$customer->save();
|
|
|
|
|
|
|
|
|
|
$row = new Shayegan();
|
|
|
|
|
$row->customer_id = (int) $customer->id;
|
|
|
|
|
$row->order_id = null;
|
|
|
|
|
$row->withdraw_id = null;
|
|
|
|
|
$row->product_id = null;
|
|
|
|
|
$row->amount = null;
|
2026-11-02 13:05:53 +03:30
|
|
|
$row->desc = 'ثبت تفصیلی شناور مشتری';
|
2026-10-28 16:51:47 +03:30
|
|
|
$row->type = self::TYPE_CUSTOMER;
|
|
|
|
|
$row->date = now();
|
2026-11-02 13:05:53 +03:30
|
|
|
$row->endpoint = 'Job/Put';
|
2026-10-28 16:51:47 +03:30
|
|
|
$row->request_guid = $guid;
|
|
|
|
|
$row->shayegan_number = $accountNumber;
|
|
|
|
|
$row->shayegan_guid = $result['guid'] ?? null;
|
|
|
|
|
$row->sync_status = self::STATUS_SUCCESS;
|
|
|
|
|
$row->data = json_encode($this->client->redactPayload([
|
|
|
|
|
'Config' => $this->client->buildConfig(),
|
|
|
|
|
'PutObject' => $put,
|
|
|
|
|
]), JSON_UNESCAPED_UNICODE);
|
|
|
|
|
$row->res = json_encode($result['body'] ?? [], JSON_UNESCAPED_UNICODE);
|
|
|
|
|
$row->save();
|
|
|
|
|
|
2026-11-02 11:21:36 +03:30
|
|
|
return array_merge([
|
2026-10-28 16:51:47 +03:30
|
|
|
'ok' => true,
|
2026-11-02 13:05:53 +03:30
|
|
|
'message' => 'تفصیلی شناور مشتری در شایگان ایجاد شد',
|
2026-10-28 16:51:47 +03:30
|
|
|
'moshtari_code' => $accountNumber,
|
2026-11-02 11:21:36 +03:30
|
|
|
], $exchange);
|
2026-10-28 16:51:47 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $data
|
|
|
|
|
*/
|
|
|
|
|
public function resolveItemNumber(array $data): string
|
|
|
|
|
{
|
|
|
|
|
$productPayload = is_array($data['product'] ?? null) ? $data['product'] : [];
|
|
|
|
|
$fromPayload = trim((string) ($productPayload['shayegan_item_number'] ?? ''));
|
|
|
|
|
if ($fromPayload !== '') {
|
|
|
|
|
return $fromPayload;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$productId = (int) ($data['product_id'] ?? ($productPayload['id'] ?? 0));
|
|
|
|
|
if ($productId > 0 && Product::hasShayeganItemColumn()) {
|
|
|
|
|
$product = Product::query()->select('id', 'shayegan_item_number')->find($productId);
|
|
|
|
|
if ($product) {
|
|
|
|
|
$item = trim((string) ($product->shayegan_item_number ?? ''));
|
|
|
|
|
if ($item !== '') {
|
|
|
|
|
return $item;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return trim($this->config->getString('shayegan_item_copper', ''));
|
2026-10-05 17:12:43 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-28 16:51:47 +03:30
|
|
|
$productId = (int) ($data['product_id'] ?? ($data['order']['product_id'] ?? 0));
|
|
|
|
|
|
|
|
|
|
$existing = $this->findExisting(
|
|
|
|
|
self::TYPE_TRADE,
|
|
|
|
|
$orderId > 0 ? $orderId : null,
|
|
|
|
|
null,
|
|
|
|
|
$customerId,
|
|
|
|
|
$productId > 0 ? $productId : null
|
|
|
|
|
);
|
2026-10-05 17:12:43 +03:30
|
|
|
if ($existing && $this->shouldSkipSend($existing)) {
|
|
|
|
|
return $existing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$customer = Customer::find($customerId);
|
2026-10-28 16:51:47 +03:30
|
|
|
$accountNumber = $this->ensureCustomerAccountNumber($customer);
|
2026-10-05 17:12:43 +03:30
|
|
|
if ($accountNumber === '') {
|
2026-10-28 16:51:47 +03:30
|
|
|
$msg = $this->customerMappingReady()
|
|
|
|
|
? 'ثبت حساب تفصیلی مشتری در شایگان ناموفق بود.'
|
|
|
|
|
: 'کد حساب مشتری خالی است؛ shayegan_moin_customer را در تنظیمات پر کنید.';
|
|
|
|
|
|
|
|
|
|
return $this->markLocalFailed($existing, $customerId, $orderId, null, self::TYPE_TRADE, $msg, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$itemNumber = $this->resolveItemNumber($data);
|
|
|
|
|
if ($itemNumber === '') {
|
|
|
|
|
return $this->markLocalFailed(
|
|
|
|
|
$existing,
|
|
|
|
|
$customerId,
|
|
|
|
|
$orderId,
|
|
|
|
|
null,
|
|
|
|
|
self::TYPE_TRADE,
|
|
|
|
|
'کد کالای شایگان برای این محصول تنظیم نشده (محصول یا shayegan_item_copper).',
|
|
|
|
|
null
|
|
|
|
|
);
|
2026-10-05 17:12:43 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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,
|
2026-10-28 16:51:47 +03:30
|
|
|
'ItemNumber' => $itemNumber,
|
2026-10-05 17:12:43 +03:30
|
|
|
'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;
|
2026-10-28 16:51:47 +03:30
|
|
|
if (Schema::hasColumn('shayegan', 'product_id')) {
|
|
|
|
|
$row->product_id = $productId > 0 ? $productId : null;
|
|
|
|
|
}
|
2026-10-05 17:12:43 +03:30
|
|
|
$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);
|
2026-10-28 16:51:47 +03:30
|
|
|
$accountNumber = $this->ensureCustomerAccountNumber($customer);
|
2026-10-05 17:12:43 +03:30
|
|
|
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);
|
2026-10-28 16:51:47 +03:30
|
|
|
$accountNumber = $this->ensureCustomerAccountNumber($customer);
|
2026-10-05 17:12:43 +03:30
|
|
|
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;
|
2026-10-28 16:51:47 +03:30
|
|
|
$itemNumber = $this->resolveItemNumber($data);
|
|
|
|
|
if ($itemNumber === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-10-05 17:12:43 +03:30
|
|
|
$put = [[
|
|
|
|
|
'Body' => [[
|
|
|
|
|
'STNumber' => $this->config->getString('shayegan_stock_number', ''),
|
2026-10-28 16:51:47 +03:30
|
|
|
'ItemNumber' => $itemNumber,
|
2026-10-05 17:12:43 +03:30
|
|
|
'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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-28 16:51:47 +03:30
|
|
|
private function findExisting(
|
|
|
|
|
string $type,
|
|
|
|
|
?int $orderId,
|
|
|
|
|
?int $withdrawId,
|
|
|
|
|
int $customerId,
|
|
|
|
|
?int $productId = null
|
|
|
|
|
): ?Shayegan {
|
2026-10-05 17:12:43 +03:30
|
|
|
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);
|
|
|
|
|
}
|
2026-10-28 16:51:47 +03:30
|
|
|
if ($productId && Schema::hasColumn('shayegan', 'product_id')) {
|
|
|
|
|
$q->where('product_id', $productId);
|
|
|
|
|
}
|
2026-10-05 17:12:43 +03:30
|
|
|
if (! $orderId && ! $withdrawId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $q->orderByDesc('id')->first();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-28 16:51:47 +03:30
|
|
|
private function ensureCustomerAccountNumber(?Customer $customer): string
|
|
|
|
|
{
|
|
|
|
|
$code = $this->customerAccountNumber($customer);
|
|
|
|
|
if ($code !== '' || ! $customer) {
|
|
|
|
|
return $code;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! $this->customerMappingReady()) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$result = $this->registerCustomer($customer);
|
|
|
|
|
if (! ($result['ok'] ?? false)) {
|
|
|
|
|
Log::info('shayegan.ensure_customer_failed', [
|
|
|
|
|
'customer_id' => $customer->id,
|
|
|
|
|
'message' => $result['message'] ?? null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$customer->refresh();
|
|
|
|
|
|
|
|
|
|
return $this->customerAccountNumber($customer);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 17:12:43 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|