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_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) در تنظیمات خالی است']; } $name = trim((string) ($customer->name ?? '')); if ($name === '') { $name = 'مشتری ' . $customer->id; } // تفصیلی شناور: کل ثابت + کد کاربری ۷رقمی. مشتری ۱ → 1210000001 $requestedAccountNumber = $this->customerTafsilNumber((int) $customer->id); $guid = (string) Str::uuid(); $put = [[ 'JobNumber' => $requestedAccountNumber, 'JobName' => mb_substr($name, 0, 200), 'Mobile' => trim((string) ($customer->mobile ?? '')) ?: null, 'mellicode' => trim((string) ($customer->n_code ?? '')) ?: null, 'GuId' => $guid, 'UpdateKind' => 0, ]]; $jobGroup = trim($this->config->getString('shayegan_job_group', '')); if ($jobGroup !== '') { $put[0]['JobGroupNumber'] = $jobGroup; } $result = $this->client->put('Job/Put', $put); $accountNumber = trim((string) ($result['number'] ?? '')); $exchange = $this->client->debugExchange($result); if (! empty($result['timeout'])) { return array_merge([ 'ok' => false, 'message' => (string) ($result['message'] ?? 'timeout'), ], $exchange); } if (empty($result['ok']) || $accountNumber === '') { return array_merge([ 'ok' => false, 'message' => (string) ($result['message'] ?? 'ثبت حساب مشتری در شایگان ناموفق بود'), ], $exchange); } $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; $row->desc = 'ثبت تفصیلی شناور مشتری'; $row->type = self::TYPE_CUSTOMER; $row->date = now(); $row->endpoint = 'Job/Put'; $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(); return array_merge([ 'ok' => true, 'message' => 'تفصیلی شناور مشتری در شایگان ایجاد شد', 'moshtari_code' => $accountNumber, ], $exchange); } /** * @param array $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', '')); } 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; } $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 ); if ($existing && $this->shouldSkipSend($existing)) { return $existing; } $customer = Customer::find($customerId); $accountNumber = $this->ensureCustomerAccountNumber($customer); if ($accountNumber === '') { $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 ); } $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' => $itemNumber, '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; if (Schema::hasColumn('shayegan', 'product_id')) { $row->product_id = $productId > 0 ? $productId : 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->ensureCustomerAccountNumber($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->ensureCustomerAccountNumber($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; $itemNumber = $this->resolveItemNumber($data); if ($itemNumber === '') { return null; } $put = [[ 'Body' => [[ 'STNumber' => $this->config->getString('shayegan_stock_number', ''), 'ItemNumber' => $itemNumber, '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, ?int $productId = null ): ?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 ($productId && Schema::hasColumn('shayegan', 'product_id')) { $q->where('product_id', $productId); } if (! $orderId && ! $withdrawId) { return null; } return $q->orderByDesc('id')->first(); } 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); } private function customerAccountNumber(?Customer $customer): string { if (! $customer) { return ''; } return trim((string) ($customer->moshtari_code ?? '')); } /** کد کل (پیش‌فرض 121) + کد کاربری با صفر تا ۷ رقم. */ private function customerTafsilNumber(int $customerId): string { $kol = trim($this->config->getString('shayegan_kol_customer', '121')); if ($kol === '') { $kol = '121'; } return $kol . str_pad((string) $customerId, 7, '0', STR_PAD_LEFT); } 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; } }