feat(shayegan): per-product item code and auto customer Account/Put

This commit is contained in:
2026-10-28 16:51:47 +03:30
parent 25ee47b473
commit 9528056036
10 changed files with 344 additions and 13 deletions
@@ -3,7 +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-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 | لاگ ترافیک شایگان | `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-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 `{}` باشد نه `[]` - 2026-10-19 | Auth وب‌سرویس شایگان | `shayegan_auth_user` / `shayegan_auth_password` | کاربر وب جدا از sa؛ Domain خالی باید JSON `{}` باشد نه `[]`
@@ -979,6 +979,13 @@ class ProductController extends SuperController
unset($data['cash_fee_buy_is_percent'], $data['cash_fee_sell_is_percent']); 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; return $data;
} }
@@ -62,6 +62,18 @@ class ShayeganController extends Controller
return $this->sync->recordWalletGold($data); 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) public function table(Request $request)
{ {
if (! Schema::hasTable('shayegan')) { if (! Schema::hasTable('shayegan')) {
@@ -421,6 +421,9 @@ class CartController extends Controller
'amount' => $product->amount, 'amount' => $product->amount,
'unit' => $product->unit, 'unit' => $product->unit,
'trade_by_quantity' => $product->trade_by_quantity, 'trade_by_quantity' => $product->trade_by_quantity,
'shayegan_item_number' => Product::hasShayeganItemColumn()
? ($product->shayegan_item_number ?? null)
: null,
], ],
'line' => $lineIndex + 1, 'line' => $lineIndex + 1,
'order' => [ 'order' => [
+20
View File
@@ -33,6 +33,7 @@ class Product extends Model
private static ?bool $karatColumnExists = null; private static ?bool $karatColumnExists = null;
private static ?bool $tvPriceFormulaColumnsExist = null; private static ?bool $tvPriceFormulaColumnsExist = null;
private static ?bool $priceFormulaColumnsExist = null; private static ?bool $priceFormulaColumnsExist = null;
private static ?bool $shayeganItemColumnExists = null;
protected static function booted(): void protected static function booted(): void
{ {
@@ -104,9 +105,27 @@ class Product extends Model
$product->attributes['tv_price_formula'] $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 public static function hasTvPriceFormulaColumns(): bool
{ {
if (static::$tvPriceFormulaColumnsExist !== null) { if (static::$tvPriceFormulaColumnsExist !== null) {
@@ -497,6 +516,7 @@ class Product extends Model
'unit', // The unit of the item. 'unit', // The unit of the item.
'group_type', // The unit of the item. 'group_type', // The unit of the item.
'finance_p_name', // کد کار زرین (idKar) 'finance_p_name', // کد کار زرین (idKar)
'shayegan_item_number', // ItemNumber در فاکتور شایگان
'file_id', // The ID of the associated file. 'file_id', // The ID of the associated file.
'can_canceled', // Whether the record can be canceled (boolean). 'can_canceled', // Whether the record can be canceled (boolean).
'active', // The active status of the record (boolean). 'active', // The active status of the record (boolean).
+1
View File
@@ -14,5 +14,6 @@ class CustomerObserver
app(TahesabSyncService::class)->registerOnCreate($customer); app(TahesabSyncService::class)->registerOnCreate($customer);
app(ZarinSyncService::class)->registerOnCreate($customer); app(ZarinSyncService::class)->registerOnCreate($customer);
app(PargarSyncService::class)->registerOnCreate($customer); app(PargarSyncService::class)->registerOnCreate($customer);
app(\App\Services\ShayeganSyncService::class)->registerOnCreate($customer);
} }
} }
+225 -11
View File
@@ -3,6 +3,7 @@
namespace App\Services; namespace App\Services;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Product;
use App\Models\Shayegan; use App\Models\Shayegan;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -50,8 +51,155 @@ class ShayeganSyncService
public function tradeMappingReady(): bool public function tradeMappingReady(): bool
{ {
return $this->config->getString('shayegan_item_copper', '') !== '' return $this->config->getString('shayegan_stock_number', '') !== '';
&& $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<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', ''));
} }
public function cashMappingReady(): bool public function cashMappingReady(): bool
@@ -92,15 +240,40 @@ class ShayeganSyncService
return null; 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)) { if ($existing && $this->shouldSkipSend($existing)) {
return $existing; return $existing;
} }
$customer = Customer::find($customerId); $customer = Customer::find($customerId);
$accountNumber = $this->customerAccountNumber($customer); $accountNumber = $this->ensureCustomerAccountNumber($customer);
if ($accountNumber === '') { 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; $isBuy = (int) ($data['direction'] ?? 0) === 1;
@@ -120,7 +293,7 @@ class ShayeganSyncService
'Body' => [[ 'Body' => [[
'STNumber' => $this->config->getString('shayegan_stock_number', ''), 'STNumber' => $this->config->getString('shayegan_stock_number', ''),
'STGuId' => null, 'STGuId' => null,
'ItemNumber' => $this->config->getString('shayegan_item_copper', ''), 'ItemNumber' => $itemNumber,
'ItemGuId' => null, 'ItemGuId' => null,
'Quan' => $quan, 'Quan' => $quan,
'Quan2' => null, 'Quan2' => null,
@@ -146,6 +319,9 @@ class ShayeganSyncService
$row = $existing ?: new Shayegan(); $row = $existing ?: new Shayegan();
$row->customer_id = $customerId; $row->customer_id = $customerId;
$row->order_id = $orderId > 0 ? $orderId : null; $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->withdraw_id = null;
$row->amount = $this->scaleAmount((float) ($data['total'] ?? $data['total_price'] ?? ($quan * $price))); $row->amount = $this->scaleAmount((float) ($data['total'] ?? $data['total_price'] ?? ($quan * $price)));
$row->desc = $put[0]['InvDescription']; $row->desc = $put[0]['InvDescription'];
@@ -183,7 +359,7 @@ class ShayeganSyncService
$customerId = (int) ($data['customer_id'] ?? 0); $customerId = (int) ($data['customer_id'] ?? 0);
$withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null; $withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null;
$customer = Customer::find($customerId); $customer = Customer::find($customerId);
$accountNumber = $this->customerAccountNumber($customer); $accountNumber = $this->ensureCustomerAccountNumber($customer);
if ($accountNumber === '') { if ($accountNumber === '') {
return null; return null;
} }
@@ -269,7 +445,7 @@ class ShayeganSyncService
$customerId = (int) ($data['customer_id'] ?? 0); $customerId = (int) ($data['customer_id'] ?? 0);
$withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null; $withdrawId = isset($data['withdraw_id']) ? (int) $data['withdraw_id'] : null;
$customer = Customer::find($customerId); $customer = Customer::find($customerId);
$accountNumber = $this->customerAccountNumber($customer); $accountNumber = $this->ensureCustomerAccountNumber($customer);
if ($accountNumber === '') { if ($accountNumber === '') {
return null; return null;
} }
@@ -294,10 +470,14 @@ class ShayeganSyncService
return null; return null;
} }
$invTyp = $isDeposit ? self::INV_RECEIPT : self::INV_HAVALE; $invTyp = $isDeposit ? self::INV_RECEIPT : self::INV_HAVALE;
$itemNumber = $this->resolveItemNumber($data);
if ($itemNumber === '') {
return null;
}
$put = [[ $put = [[
'Body' => [[ 'Body' => [[
'STNumber' => $this->config->getString('shayegan_stock_number', ''), 'STNumber' => $this->config->getString('shayegan_stock_number', ''),
'ItemNumber' => $this->config->getString('shayegan_item_copper', ''), 'ItemNumber' => $itemNumber,
'Quan' => $quan, 'Quan' => $quan,
'Price' => null, 'Price' => null,
'LineItemDesc' => $isDeposit ? 'واریز محصول' : 'برداشت محصول', 'LineItemDesc' => $isDeposit ? 'واریز محصول' : 'برداشت محصول',
@@ -401,8 +581,13 @@ class ShayeganSyncService
return false; 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')) { if (! Schema::hasTable('shayegan')) {
return null; return null;
} }
@@ -414,6 +599,9 @@ class ShayeganSyncService
if ($withdrawId) { if ($withdrawId) {
$q->where('withdraw_id', $withdrawId); $q->where('withdraw_id', $withdrawId);
} }
if ($productId && Schema::hasColumn('shayegan', 'product_id')) {
$q->where('product_id', $productId);
}
if (! $orderId && ! $withdrawId) { if (! $orderId && ! $withdrawId) {
return null; return null;
} }
@@ -421,6 +609,32 @@ class ShayeganSyncService
return $q->orderByDesc('id')->first(); 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 private function customerAccountNumber(?Customer $customer): string
{ {
if (! $customer) { if (! $customer) {
@@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('products') && ! Schema::hasColumn('products', 'shayegan_item_number')) {
Schema::table('products', function (Blueprint $table) {
$table->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(),
]);
}
};
+1 -1
View File
@@ -52,7 +52,7 @@ Auth: Shayegan cloud / Hesabgar / none (empty Auth*).
| Health (read) | `POST .../api/Account/Get?RowStart=0&RowCount=1` | | Health (read) | `POST .../api/Account/Get?RowStart=0&RowCount=1` |
| Trade | `POST .../api/Invoice/Put` | | Trade | `POST .../api/Invoice/Put` |
| Rial ledger | `POST .../api/Document/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) ## Put response (check even on HTTP 200)
+1
View File
@@ -297,6 +297,7 @@ Route::middleware(['auth:user', 'admin.page'])->group(function (){
Route::post('shayegan/table', [ShayeganController::class, 'table']); Route::post('shayegan/table', [ShayeganController::class, 'table']);
Route::get('shayegan/{id}', [ShayeganController::class, 'show']); Route::get('shayegan/{id}', [ShayeganController::class, 'show']);
Route::post('shayegan/{id}/retry', [ShayeganController::class, 'retrySanad']); 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::post('pargar/table', [PargarController::class, 'table']);
Route::get('pargar/customer-sync/status', [PargarController::class, 'customerSyncStatus']); Route::get('pargar/customer-sync/status', [PargarController::class, 'customerSyncStatus']);