config = $config; $this->client = $client; } public function isSyncEnabled(): bool { return $this->config->isShayeganAccounting() && $this->client->isConfigured(); } public function tradeMappingReady(): bool { return $this->config->getString('shayegan_item_copper', '') !== '' && $this->config->getString('shayegan_stock_number', '') !== ''; } public function cashMappingReady(): bool { return $this->config->getString('shayegan_bank_account', '') !== ''; } public function metalWalletMode(): string { $mode = strtolower(trim($this->config->getString('shayegan_metal_wallet_mode', 'skip'))); if (in_array($mode, ['skip', 'document', 'receipt_havale'], true)) { return $mode; } return 'skip'; } /** * After confirmed trade: one Invoice only (no duplicate Document). * Customer buy → company sale (InvTyp 2); customer sell → company purchase (InvTyp 3). * * @param array $data */ public function recordTrade(array $data): ?Shayegan { if (! $this->isSyncEnabled()) { return null; } if (! $this->tradeMappingReady()) { Log::info('shayegan.trade_skipped_mapping', ['order_id' => $data['order']->id ?? null]); return null; } $order = $data['order'] ?? null; $orderId = $order ? (int) $order->id : (int) ($data['order_id'] ?? 0); $customerId = (int) ($data['customer_id'] ?? ($order->customer_id ?? 0)); if ($customerId <= 0) { return null; } $existing = $this->findExisting(self::TYPE_TRADE, $orderId > 0 ? $orderId : null, null, $customerId); if ($existing && $this->shouldSkipSend($existing)) { return $existing; } $customer = Customer::find($customerId); $accountNumber = $this->customerAccountNumber($customer); if ($accountNumber === '') { return $this->markLocalFailed($existing, $customerId, $orderId, null, self::TYPE_TRADE, 'کد حساب مشتری در شایگان خالی است (moshtari_code).', null); } $isBuy = (int) ($data['direction'] ?? 0) === 1; $invTyp = $isBuy ? self::INV_SALE : self::INV_PURCHASE; $quan = $this->scaleWeight((float) ($data['number'] ?? $data['amount'] ?? 0)); $price = $this->scaleAmount((float) ($data['price'] ?? $data['fee'] ?? 0)); if ($quan <= 0) { return $this->markLocalFailed($existing, $customerId, $orderId, null, self::TYPE_TRADE, 'مقدار وزن معامله برای شایگان صفر است.', null); } $guid = $existing && $existing->request_guid ? (string) $existing->request_guid : (string) Str::uuid(); $today = date('Ymd'); $put = [[ 'Body' => [[ 'STNumber' => $this->config->getString('shayegan_stock_number', ''), 'STGuId' => null, 'ItemNumber' => $this->config->getString('shayegan_item_copper', ''), 'ItemGuId' => null, 'Quan' => $quan, 'Quan2' => null, 'Price' => $price > 0 ? $price : null, 'Price2' => null, 'LineDiscAmount' => null, 'LineItemDesc' => $isBuy ? 'خرید مس مشتری' : 'فروش مس مشتری', 'UpdateKind' => 0, ]], 'Expense' => [], 'InvTyp' => $invTyp, 'InvNo' => null, 'InvDescription' => $isBuy ? 'فروش مس به مشتری' : 'خرید مس از مشتری', 'InvDate' => $today, 'InvPayDue' => $today, 'AccountNumber' => $accountNumber, 'AccountGuId' => null, 'GeneralRef' => $orderId > 0 ? ('order:' . $orderId) : null, 'GuId' => $guid, 'UpdateKind' => 0, ]]; $row = $existing ?: new Shayegan(); $row->customer_id = $customerId; $row->order_id = $orderId > 0 ? $orderId : null; $row->withdraw_id = null; $row->amount = $this->scaleAmount((float) ($data['total'] ?? $data['total_price'] ?? ($quan * $price))); $row->desc = $put[0]['InvDescription']; $row->type = self::TYPE_TRADE; $row->date = now(); $row->endpoint = 'Invoice/Put'; $row->request_guid = $guid; $row->data = json_encode($this->client->redactPayload([ 'Config' => $this->client->buildConfig(), 'PutObject' => $put, ]), JSON_UNESCAPED_UNICODE); $row->sync_status = self::STATUS_PENDING; $row->sync_log = null; $row->save(); return $this->dispatchPut($row, 'Invoice/Put', $put); } /** * Rial wallet: Document only (not Invoice). * direction: 0 deposit, 1 withdraw (same as Zarin/Tahesab wallet cash). * * @param array $data */ public function recordWalletCash(array $data): ?Shayegan { if (! $this->isSyncEnabled()) { return null; } if (! $this->cashMappingReady()) { Log::info('shayegan.cash_skipped_mapping', ['withdraw_id' => $data['withdraw_id'] ?? null]); return null; } $customerId = (int) ($data['customer_id'] ?? 0); $withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null; $customer = Customer::find($customerId); $accountNumber = $this->customerAccountNumber($customer); if ($accountNumber === '') { return null; } $existing = $this->findExisting(self::TYPE_WALLET_CASH, null, $withdrawId, $customerId); if ($existing && $this->shouldSkipSend($existing)) { return $existing; } $isDeposit = (int) ($data['direction'] ?? 0) === 0; $amount = $this->scaleAmount(abs((float) ($data['value'] ?? 0))); if ($amount <= 0) { return null; } $bank = $this->config->getString('shayegan_bank_account', ''); $guid = $existing && $existing->request_guid ? (string) $existing->request_guid : (string) Str::uuid(); // Deposit: bank debit + customer credit. Withdraw: customer debit + bank credit. if ($isDeposit) { $body = [ ['AccountNumber' => $bank, 'AmountBed' => $amount, 'AmountBes' => 0, 'RialBed' => $amount, 'RialBes' => 0, 'DocRowDesc' => 'واریز ریالی'], ['AccountNumber' => $accountNumber, 'AmountBed' => 0, 'AmountBes' => $amount, 'RialBed' => 0, 'RialBes' => $amount, 'DocRowDesc' => 'واریز ریالی مشتری'], ]; $desc = 'واریز ریالی'; } else { $body = [ ['AccountNumber' => $accountNumber, 'AmountBed' => $amount, 'AmountBes' => 0, 'RialBed' => $amount, 'RialBes' => 0, 'DocRowDesc' => 'برداشت ریالی مشتری'], ['AccountNumber' => $bank, 'AmountBed' => 0, 'AmountBes' => $amount, 'RialBed' => 0, 'RialBes' => $amount, 'DocRowDesc' => 'برداشت ریالی'], ]; $desc = 'برداشت ریالی'; } $put = [[ 'Body' => $body, 'DocDescription' => trim((string) ($data['note'] ?? '')) !== '' ? (string) $data['note'] : $desc, 'DocDate' => date('Ymd'), 'DocReference' => $withdrawId ? ('withdraw:' . $withdrawId) : null, 'GuId' => $guid, 'UpdateKind' => 0, ]]; $row = $existing ?: new Shayegan(); $row->customer_id = $customerId; $row->order_id = null; $row->withdraw_id = $withdrawId; $row->amount = $amount; $row->desc = $desc; $row->type = self::TYPE_WALLET_CASH; $row->date = now(); $row->endpoint = 'Document/Put'; $row->request_guid = $guid; $row->data = json_encode($this->client->redactPayload([ 'Config' => $this->client->buildConfig(), 'PutObject' => $put, ]), JSON_UNESCAPED_UNICODE); $row->sync_status = self::STATUS_PENDING; $row->sync_log = null; $row->save(); return $this->dispatchPut($row, 'Document/Put', $put); } /** * Product / metal wallet movement — never invent warehouse unless mode says so. * direction: 1 deposit, 0 withdraw (walletGold convention). * * @param array $data */ public function recordWalletGold(array $data): ?Shayegan { if (! $this->isSyncEnabled()) { return null; } $mode = $this->metalWalletMode(); if ($mode === 'skip') { return null; } $customerId = (int) ($data['customer_id'] ?? 0); $withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null; $customer = Customer::find($customerId); $accountNumber = $this->customerAccountNumber($customer); if ($accountNumber === '') { return null; } $existing = $this->findExisting(self::TYPE_WALLET_GOLD, null, $withdrawId, $customerId); if ($existing && $this->shouldSkipSend($existing)) { return $existing; } $isDeposit = (int) ($data['direction'] ?? 1) === 1; $quan = $this->scaleWeight(abs((float) ($data['value'] ?? 0))); if ($quan <= 0) { return null; } $guid = $existing && $existing->request_guid ? (string) $existing->request_guid : (string) Str::uuid(); if ($mode === 'receipt_havale') { if (! $this->tradeMappingReady()) { return null; } $invTyp = $isDeposit ? self::INV_RECEIPT : self::INV_HAVALE; $put = [[ 'Body' => [[ 'STNumber' => $this->config->getString('shayegan_stock_number', ''), 'ItemNumber' => $this->config->getString('shayegan_item_copper', ''), 'Quan' => $quan, 'Price' => null, 'LineItemDesc' => $isDeposit ? 'واریز محصول' : 'برداشت محصول', 'UpdateKind' => 0, ]], 'Expense' => [], 'InvTyp' => $invTyp, 'InvNo' => null, 'InvDescription' => $isDeposit ? 'رسید انبار (واریز محصول)' : 'حواله (برداشت محصول)', 'InvDate' => date('Ymd'), 'AccountNumber' => $accountNumber, 'GeneralRef' => $withdrawId ? ('withdraw:' . $withdrawId) : null, 'GuId' => $guid, 'UpdateKind' => 0, ]]; $endpoint = 'Invoice/Put'; $desc = $put[0]['InvDescription']; } else { // document mode: accounting note only — no stock invoice $bank = $this->config->getString('shayegan_bank_account', ''); if ($bank === '') { Log::info('shayegan.gold_document_needs_bank_or_switch_mode'); return null; } // Placeholder balanced zero-amount skip — without operator chart of accounts for metal, // document mode requires explicit counterpart accounts; refuse rather than invent. Log::info('shayegan.gold_document_needs_account_map', [ 'hint' => 'Provide metal counterpart accounts or use receipt_havale after confirming warehouse semantics.', ]); return null; } $row = $existing ?: new Shayegan(); $row->customer_id = $customerId; $row->order_id = null; $row->withdraw_id = $withdrawId; $row->amount = $quan; $row->desc = $desc; $row->type = self::TYPE_WALLET_GOLD; $row->date = now(); $row->endpoint = $endpoint; $row->request_guid = $guid; $row->data = json_encode($this->client->redactPayload([ 'Config' => $this->client->buildConfig(), 'PutObject' => $put, ]), JSON_UNESCAPED_UNICODE); $row->sync_status = self::STATUS_PENDING; $row->sync_log = null; $row->save(); return $this->dispatchPut($row, $endpoint, $put); } /** * @param array> $putObject */ private function dispatchPut(Shayegan $row, string $endpoint, array $putObject): Shayegan { $result = $this->client->put($endpoint, $putObject); if (! empty($result['timeout'])) { $row->sync_status = self::STATUS_TIMEOUT; $row->sync_log = (string) ($result['message'] ?? 'timeout'); $row->res = json_encode(['timeout' => true, 'message' => $result['message'] ?? null], JSON_UNESCAPED_UNICODE); $row->save(); return $row; } if (! empty($result['ok'])) { $row->sync_status = self::STATUS_SUCCESS; $row->sync_log = null; $row->shayegan_number = $result['number'] ?? null; $row->shayegan_guid = $result['guid'] ?? null; $row->res = json_encode($result['body'] ?? [], JSON_UNESCAPED_UNICODE); $row->save(); return $row; } $row->sync_status = self::STATUS_FAILED; $row->sync_log = (string) ($result['message'] ?? 'failed'); $row->res = json_encode($result['body'] ?? ['message' => $result['message'] ?? null], JSON_UNESCAPED_UNICODE); $row->save(); return $row; } private function shouldSkipSend(Shayegan $row): bool { $status = (string) $row->sync_status; if ($status === self::STATUS_SUCCESS) { return true; } // Never auto-resend after timeout without manual verification. if ($status === self::STATUS_TIMEOUT) { return true; } return false; } private function findExisting(string $type, ?int $orderId, ?int $withdrawId, int $customerId): ?Shayegan { if (! Schema::hasTable('shayegan')) { return null; } $q = Shayegan::query()->where('type', $type)->where('customer_id', $customerId); if ($orderId) { $q->where('order_id', $orderId); } if ($withdrawId) { $q->where('withdraw_id', $withdrawId); } if (! $orderId && ! $withdrawId) { return null; } return $q->orderByDesc('id')->first(); } private function customerAccountNumber(?Customer $customer): string { if (! $customer) { return ''; } return trim((string) ($customer->moshtari_code ?? '')); } private function scaleAmount(float $amount): float { $scale = (float) $this->config->getString('shayegan_amount_scale', '1'); if ($scale <= 0) { $scale = 1.0; } return round($amount * $scale, 4); } private function scaleWeight(float $weight): float { $scale = (float) $this->config->getString('shayegan_weight_scale', '1'); if ($scale <= 0) { $scale = 1.0; } return round($weight * $scale, 6); } private function markLocalFailed( ?Shayegan $existing, int $customerId, ?int $orderId, ?int $withdrawId, string $type, string $message, $amount ): Shayegan { $row = $existing ?: new Shayegan(); $row->customer_id = $customerId; $row->order_id = $orderId; $row->withdraw_id = $withdrawId; $row->amount = $amount; $row->type = $type; $row->date = now(); $row->sync_status = self::STATUS_FAILED; $row->sync_log = $message; $row->save(); return $row; } }