diff --git a/.cursor/skills/abshode-domain-lookup/learned-notes.md b/.cursor/skills/abshode-domain-lookup/learned-notes.md index 27ff1f5..b9cd63a 100644 --- a/.cursor/skills/abshode-domain-lookup/learned-notes.md +++ b/.cursor/skills/abshode-domain-lookup/learned-notes.md @@ -3,7 +3,7 @@ فرمت هر خط: - YYYY-MM-DD | موضوع | مسیر فایل(ها) | نکتهٔ ۱ خطی -- 2026-10-05 | شایگان حسابداری | `ConfigService::accountingProvider` همیشه shayegan + `ShayeganSyncService` | Ws: `2.180.4.172:2030` (عمومی) / `192.168.109.222:2030` (داخلی); SQL Config P-SERVER/CYPRSD04; ارسال وقتی mapping کامل باشد +- 2026-10-28 | شایگان کالا/مشتری | `products.shayegan_item_number` + `ShayeganSyncService::registerCustomer` Account/Put + `ensureCustomerAccountNumber` | بانک 10001 و معین 12101 در settings؛ ItemNumber per-product - 2026-10-17 | لاگ ترافیک شایگان | `ShayeganClient::request` | `Log::info` با کلید `shayegan.http_request` / `shayegan.http_response`; رمز `***`; خاموش: setting `shayegan_log_traffic=0` - 2026-10-17 | گروه شایگان در تنظیمات پیشرفته | `settingGroups.resolveSettingMenu` + migration `2026_10_17_000001` | اگر `menu=misc` ولی کلید `shayegan_*` → گروه accounting_shayegan؛ نه «سایر» - 2026-10-19 | Auth وب‌سرویس شایگان | `shayegan_auth_user` / `shayegan_auth_password` | کاربر وب جدا از sa؛ Domain خالی باید JSON `{}` باشد نه `[]` diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index 30e1be5..6479f36 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -979,6 +979,13 @@ class ProductController extends SuperController unset($data['cash_fee_buy_is_percent'], $data['cash_fee_sell_is_percent']); } + if (! Product::hasShayeganItemColumn()) { + unset($data['shayegan_item_number']); + } elseif (array_key_exists('shayegan_item_number', $data)) { + $item = trim((string) $data['shayegan_item_number']); + $data['shayegan_item_number'] = $item === '' ? null : $item; + } + return $data; } diff --git a/app/Http/Controllers/Admin/ShayeganController.php b/app/Http/Controllers/Admin/ShayeganController.php index 05cda9b..d6e4f92 100644 --- a/app/Http/Controllers/Admin/ShayeganController.php +++ b/app/Http/Controllers/Admin/ShayeganController.php @@ -62,6 +62,18 @@ class ShayeganController extends Controller return $this->sync->recordWalletGold($data); } + public function registerCustomer($customerId) + { + if (! app(\App\Services\ConfigService::class)->isShayeganAccounting()) { + return response()->json(['ok' => false, 'message' => 'حسابداری شایگان فعال نیست'], 422); + } + + $customer = \App\Models\Customer::findOrFail($customerId); + $result = $this->sync->registerCustomer($customer); + + return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); + } + public function table(Request $request) { if (! Schema::hasTable('shayegan')) { diff --git a/app/Http/Controllers/Customer/CartController.php b/app/Http/Controllers/Customer/CartController.php index de39f39..a21b3f5 100644 --- a/app/Http/Controllers/Customer/CartController.php +++ b/app/Http/Controllers/Customer/CartController.php @@ -421,6 +421,9 @@ class CartController extends Controller 'amount' => $product->amount, 'unit' => $product->unit, 'trade_by_quantity' => $product->trade_by_quantity, + 'shayegan_item_number' => Product::hasShayeganItemColumn() + ? ($product->shayegan_item_number ?? null) + : null, ], 'line' => $lineIndex + 1, 'order' => [ diff --git a/app/Models/Product.php b/app/Models/Product.php index e02e42d..35e3852 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -33,6 +33,7 @@ class Product extends Model private static ?bool $karatColumnExists = null; private static ?bool $tvPriceFormulaColumnsExist = null; private static ?bool $priceFormulaColumnsExist = null; + private static ?bool $shayeganItemColumnExists = null; protected static function booted(): void { @@ -104,9 +105,27 @@ class Product extends Model $product->attributes['tv_price_formula'] ); } + if (!static::hasShayeganItemColumn()) { + unset($product->attributes['shayegan_item_number']); + } }); } + public static function hasShayeganItemColumn(): bool + { + if (static::$shayeganItemColumnExists !== null) { + return static::$shayeganItemColumnExists; + } + + try { + static::$shayeganItemColumnExists = Schema::hasColumn((new static)->getTable(), 'shayegan_item_number'); + } catch (\Throwable) { + static::$shayeganItemColumnExists = false; + } + + return static::$shayeganItemColumnExists; + } + public static function hasTvPriceFormulaColumns(): bool { if (static::$tvPriceFormulaColumnsExist !== null) { @@ -497,6 +516,7 @@ class Product extends Model 'unit', // The unit of the item. 'group_type', // The unit of the item. 'finance_p_name', // کد کار زرین (idKar) + 'shayegan_item_number', // ItemNumber در فاکتور شایگان 'file_id', // The ID of the associated file. 'can_canceled', // Whether the record can be canceled (boolean). 'active', // The active status of the record (boolean). diff --git a/app/Observers/CustomerObserver.php b/app/Observers/CustomerObserver.php index 189ebfd..8431b78 100644 --- a/app/Observers/CustomerObserver.php +++ b/app/Observers/CustomerObserver.php @@ -14,5 +14,6 @@ class CustomerObserver app(TahesabSyncService::class)->registerOnCreate($customer); app(ZarinSyncService::class)->registerOnCreate($customer); app(PargarSyncService::class)->registerOnCreate($customer); + app(\App\Services\ShayeganSyncService::class)->registerOnCreate($customer); } } diff --git a/app/Services/ShayeganSyncService.php b/app/Services/ShayeganSyncService.php index 8e07cf4..45c1852 100644 --- a/app/Services/ShayeganSyncService.php +++ b/app/Services/ShayeganSyncService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Customer; +use App\Models\Product; use App\Models\Shayegan; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; @@ -50,8 +51,155 @@ class ShayeganSyncService public function tradeMappingReady(): bool { - return $this->config->getString('shayegan_item_copper', '') !== '' - && $this->config->getString('shayegan_stock_number', '') !== ''; + 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', '')); + $moinNumber = is_numeric($moinRaw) ? (int) $moinRaw : $moinRaw; + $name = trim((string) ($customer->name ?? '')); + if ($name === '') { + $name = 'مشتری ' . $customer->id; + } + + $guid = (string) Str::uuid(); + $put = [[ + 'MoinNumber' => $moinNumber, + 'AccountTypeId' => null, + 'AccountName' => mb_substr($name, 0, 200), + 'Mobile' => trim((string) ($customer->mobile ?? '')) ?: null, + 'mellicode' => trim((string) ($customer->n_code ?? '')) ?: null, + 'GuId' => $guid, + 'UpdateKind' => 3, + ]]; + + $result = $this->client->put('Account/Put', $put); + $accountNumber = trim((string) ($result['number'] ?? '')); + + if (! empty($result['timeout'])) { + return ['ok' => false, 'message' => (string) ($result['message'] ?? 'timeout')]; + } + + if (empty($result['ok']) || $accountNumber === '') { + return [ + 'ok' => false, + 'message' => (string) ($result['message'] ?? 'ثبت حساب مشتری در شایگان ناموفق بود'), + ]; + } + + $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 = 'Account/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 [ + 'ok' => true, + 'message' => 'حساب تفصیلی مشتری در شایگان ایجاد شد', + 'moshtari_code' => $accountNumber, + ]; + } + + /** + * @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 @@ -92,15 +240,40 @@ class ShayeganSyncService return null; } - $existing = $this->findExisting(self::TYPE_TRADE, $orderId > 0 ? $orderId : null, null, $customerId); + $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->customerAccountNumber($customer); + $accountNumber = $this->ensureCustomerAccountNumber($customer); if ($accountNumber === '') { - return $this->markLocalFailed($existing, $customerId, $orderId, null, self::TYPE_TRADE, 'کد حساب مشتری در شایگان خالی است (moshtari_code).', null); + $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; @@ -120,7 +293,7 @@ class ShayeganSyncService 'Body' => [[ 'STNumber' => $this->config->getString('shayegan_stock_number', ''), 'STGuId' => null, - 'ItemNumber' => $this->config->getString('shayegan_item_copper', ''), + 'ItemNumber' => $itemNumber, 'ItemGuId' => null, 'Quan' => $quan, 'Quan2' => null, @@ -146,6 +319,9 @@ class ShayeganSyncService $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']; @@ -183,7 +359,7 @@ class ShayeganSyncService $customerId = (int) ($data['customer_id'] ?? 0); $withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null; $customer = Customer::find($customerId); - $accountNumber = $this->customerAccountNumber($customer); + $accountNumber = $this->ensureCustomerAccountNumber($customer); if ($accountNumber === '') { return null; } @@ -269,7 +445,7 @@ class ShayeganSyncService $customerId = (int) ($data['customer_id'] ?? 0); $withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null; $customer = Customer::find($customerId); - $accountNumber = $this->customerAccountNumber($customer); + $accountNumber = $this->ensureCustomerAccountNumber($customer); if ($accountNumber === '') { return null; } @@ -294,10 +470,14 @@ class ShayeganSyncService 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' => $this->config->getString('shayegan_item_copper', ''), + 'ItemNumber' => $itemNumber, 'Quan' => $quan, 'Price' => null, 'LineItemDesc' => $isDeposit ? 'واریز محصول' : 'برداشت محصول', @@ -401,8 +581,13 @@ class ShayeganSyncService return false; } - private function findExisting(string $type, ?int $orderId, ?int $withdrawId, int $customerId): ?Shayegan - { + private function findExisting( + string $type, + ?int $orderId, + ?int $withdrawId, + int $customerId, + ?int $productId = null + ): ?Shayegan { if (! Schema::hasTable('shayegan')) { return null; } @@ -414,6 +599,9 @@ class ShayeganSyncService if ($withdrawId) { $q->where('withdraw_id', $withdrawId); } + if ($productId && Schema::hasColumn('shayegan', 'product_id')) { + $q->where('product_id', $productId); + } if (! $orderId && ! $withdrawId) { return null; } @@ -421,6 +609,32 @@ class ShayeganSyncService 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) { diff --git a/database/migrations/2026_10_28_000001_shayegan_product_item_customer_sync.php b/database/migrations/2026_10_28_000001_shayegan_product_item_customer_sync.php new file mode 100644 index 0000000..932d7c6 --- /dev/null +++ b/database/migrations/2026_10_28_000001_shayegan_product_item_customer_sync.php @@ -0,0 +1,73 @@ +string('shayegan_item_number', 32)->nullable()->after('finance_p_name'); + }); + } + + if (Schema::hasTable('shayegan') && ! Schema::hasColumn('shayegan', 'product_id')) { + Schema::table('shayegan', function (Blueprint $table) { + $table->unsignedBigInteger('product_id')->nullable()->after('order_id')->index(); + }); + } + + if (! Schema::hasTable('settings')) { + return; + } + + $this->insertSettingIfMissing('shayegan_kol_customer', [ + 'value' => '121', + 'title_fa' => 'کد کل حساب دریافتنی مشتری (شایگان)', + 'description' => 'طبق راهنمای حسابداری بورس مس: کل 121 — معین در shayegan_moin_customer (12101). فعلاً فقط مستند؛ Account/Put از MoinNumber استفاده می‌کند.', + 'type' => 'text', + 'menu' => 'accounting_shayegan', + ]); + } + + public function down(): void + { + if (Schema::hasTable('products') && Schema::hasColumn('products', 'shayegan_item_number')) { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('shayegan_item_number'); + }); + } + + if (Schema::hasTable('shayegan') && Schema::hasColumn('shayegan', 'product_id')) { + Schema::table('shayegan', function (Blueprint $table) { + $table->dropColumn('product_id'); + }); + } + } + + /** + * @param array{value:string,title_fa:string,description:string,type:string,menu:string} $meta + */ + private function insertSettingIfMissing(string $key, array $meta): void + { + $exists = DB::table('settings')->where('key', $key)->exists(); + if ($exists) { + return; + } + + DB::table('settings')->insert([ + 'key' => $key, + 'value' => $meta['value'], + 'title_fa' => $meta['title_fa'], + 'description' => $meta['description'], + 'type' => $meta['type'], + 'menu' => $meta['menu'], + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +}; diff --git a/docs/shayegan/API_NOTES.md b/docs/shayegan/API_NOTES.md index 76a780a..0f3a748 100644 --- a/docs/shayegan/API_NOTES.md +++ b/docs/shayegan/API_NOTES.md @@ -52,7 +52,7 @@ Auth: Shayegan cloud / Hesabgar / none (empty Auth*). | 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` | +Customer account | `POST .../api/Account/Put` — `MoinNumber` (e.g. 12101), `AccountName`, `UpdateKind: 3`; response `Number` = تفصیلی stored as `moshtari_code`. ## Put response (check even on HTTP 200) diff --git a/routes/admin.php b/routes/admin.php index 774d1e6..879531e 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -297,6 +297,7 @@ Route::middleware(['auth:user', 'admin.page'])->group(function (){ Route::post('shayegan/table', [ShayeganController::class, 'table']); Route::get('shayegan/{id}', [ShayeganController::class, 'show']); Route::post('shayegan/{id}/retry', [ShayeganController::class, 'retrySanad']); + Route::post('customer/{id}/shayegan/register', [ShayeganController::class, 'registerCustomer']); Route::post('pargar/table', [PargarController::class, 'table']); Route::get('pargar/customer-sync/status', [PargarController::class, 'customerSyncStatus']);