Add Khanegi SMS sync inventory, batch import, and rollout helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,11 @@ coverage
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# SMS import target files may contain live DB passwords
|
||||||
|
prisma/khanegi-sms-targets.json
|
||||||
|
prisma/*-sms-targets.json
|
||||||
|
!prisma/khanegi-sms-targets.example.json
|
||||||
|
|
||||||
# prisma / sqlite
|
# prisma / sqlite
|
||||||
prisma/dev.db
|
prisma/dev.db
|
||||||
prisma/dev.db-journal
|
prisma/dev.db-journal
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# مراحل ساده: مهاجرت SMS و کار موازی
|
||||||
|
|
||||||
|
هدف: اول داده واقعی همکار/خانگی برود داخل سوپرادمین، بعد هر دو پنل با هم کار کنند تا خودت بگویی پنل محصولات حذف شود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## الف) یک نسخه پایلوت (مثلاً همکار)
|
||||||
|
|
||||||
|
جای `DOMAIN` دامنه همان نسخه را بگذار (همان دامنه داخل سوپرادمین).
|
||||||
|
|
||||||
|
### ۱) اگر قبلاً روی همکار `SUPERADMIN_SMS_*` گذاشتی، موقتاً خاموش کن
|
||||||
|
|
||||||
|
روی سرور همکار در `.env` این خطوط را کامنت یا حذف کن:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# SUPERADMIN_SMS_BASE_URL=...
|
||||||
|
# SUPERADMIN_SMS_SHARED_SECRET=...
|
||||||
|
# SUPERADMIN_SMS_DOMAIN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
بعد:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan config:clear
|
||||||
|
php artisan config:cache
|
||||||
|
```
|
||||||
|
|
||||||
|
علت: تا قبل از مهاجرت، پنل همکار باید همان داده واقعی محلی را نشان بدهد.
|
||||||
|
|
||||||
|
### ۲) اعداد فعلی همکار را یادداشت کن
|
||||||
|
|
||||||
|
از پنل پیامک ادمین همکار:
|
||||||
|
- موجودی
|
||||||
|
- قیمت هر پارت
|
||||||
|
- وضعیت بلاک
|
||||||
|
|
||||||
|
### ۳) سوپرادمین را با کد جدید آپدیت کن
|
||||||
|
|
||||||
|
روی سرور سوپرادمین (بعد از pull/build کد جدید):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/superadmin/api
|
||||||
|
pm2 restart superadmin-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴) مهاجرت خشک (فقط نمایش، بدون نوشتن)
|
||||||
|
|
||||||
|
روی سرور سوپرادمین، با دسترسی به MySQL همان نسخه همکار:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/superadmin/api
|
||||||
|
|
||||||
|
SMS_IMPORT_DRY_RUN=1 \
|
||||||
|
SMS_IMPORT_DOMAIN=DOMAIN \
|
||||||
|
SMS_IMPORT_PRODUCT=hamkar \
|
||||||
|
HAMKAR_DB_HOST=IP_یا_127.0.0.1 \
|
||||||
|
HAMKAR_DB_PORT=3306 \
|
||||||
|
HAMKAR_DB_USER=USER \
|
||||||
|
HAMKAR_DB_PASSWORD='PASS' \
|
||||||
|
HAMKAR_DB_DATABASE=DBNAME \
|
||||||
|
npm run prisma:import-sms-from-product
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر گفت `SKIP: sms_panel=me` → این نسخه پنل اختصاصی دارد؛ مهاجرت لازم نیست.
|
||||||
|
اگر دامنه پیدا نشد → دامنه را با همان مقدار داخل سوپرادمین چک کن.
|
||||||
|
|
||||||
|
### ۵) مهاجرت واقعی
|
||||||
|
|
||||||
|
همان دستور بدون `SMS_IMPORT_DRY_RUN=1`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMS_IMPORT_DOMAIN=DOMAIN \
|
||||||
|
SMS_IMPORT_PRODUCT=hamkar \
|
||||||
|
HAMKAR_DB_HOST=... \
|
||||||
|
HAMKAR_DB_PORT=3306 \
|
||||||
|
HAMKAR_DB_USER=... \
|
||||||
|
HAMKAR_DB_PASSWORD='...' \
|
||||||
|
HAMKAR_DB_DATABASE=... \
|
||||||
|
npm run prisma:import-sms-from-product
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۶) در سوپرادمین چک کن
|
||||||
|
|
||||||
|
همان نسخه را باز کن؛ موجودی/تعرفه/بلاک باید با یادداشت مرحله ۲ یکی باشد.
|
||||||
|
|
||||||
|
### ۷) کد همکار را آپدیت کن و remote را روشن کن
|
||||||
|
|
||||||
|
بعد از دیپلوی کد جدید همکار، در `.env` همکار:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SUPERADMIN_SMS_BASE_URL=https://adm.montzar.ir/api/remote/sms
|
||||||
|
SUPERADMIN_SMS_SHARED_SECRET=همان_رمز_سوپرادمین
|
||||||
|
# فقط اگر APP_URL دامنه درست ندارد:
|
||||||
|
# SUPERADMIN_SMS_DOMAIN=DOMAIN
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan config:clear
|
||||||
|
php artisan config:cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۸) تست موازی
|
||||||
|
|
||||||
|
1. پنل همکار و سوپرادمین را باز کن → موجودی یکی باشد
|
||||||
|
2. در سوپرادمین شارژ کن → در همکار رفرش → باید یکی شود
|
||||||
|
3. در همکار تعرفه/بلاک عوض کن → در سوپرادمین همان را ببین
|
||||||
|
4. یک SMS تست بفرست → موجودی هر دو کم شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ب) خانگی (چندنسخهای روی یک سرور)
|
||||||
|
|
||||||
|
قانون: هر نسخه = یک Tenant = یک MySQL = یک `.env` = یک `SUPERADMIN_SMS_DOMAIN`.
|
||||||
|
روی یک سرور فیزیکی ممکن است چند نصب باشد؛ هرگز یک دامنه/DB مشترک برای همه نگذار.
|
||||||
|
|
||||||
|
### ب-۱) موجودیگیری از سوپرادمین
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/superadmin/api
|
||||||
|
git pull
|
||||||
|
npm run prisma:inventory-khanegi-sms
|
||||||
|
# یا:
|
||||||
|
SMS_INVENTORY_FORMAT=json SMS_INVENTORY_OUT=/tmp/khanegi-sms-inventory.json \
|
||||||
|
npm run prisma:inventory-khanegi-sms
|
||||||
|
```
|
||||||
|
|
||||||
|
خروجی `syncStatus` / `nextAction` و لیست `multiVersionHosts` را نگه دار.
|
||||||
|
|
||||||
|
### ب-۲) فایل اهداف ایمپورت
|
||||||
|
|
||||||
|
از روی موجودی، فایل واقعی بساز (از روی نمونه):
|
||||||
|
|
||||||
|
`prisma/khanegi-sms-targets.example.json` → مثلاً `prisma/khanegi-sms-targets.json`
|
||||||
|
|
||||||
|
برای هر دامنه، MySQL **همان نسخه** را بگذار. پایلوت را `"pilot": true` کن.
|
||||||
|
|
||||||
|
### ب-۳) پایلوت: dry-run سپس import
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/superadmin/api
|
||||||
|
|
||||||
|
# فقط پایلوت، خشک
|
||||||
|
SMS_IMPORT_TARGETS=./prisma/khanegi-sms-targets.json \
|
||||||
|
SMS_IMPORT_ONLY_PILOT=1 \
|
||||||
|
SMS_IMPORT_DRY_RUN=1 \
|
||||||
|
npm run prisma:import-sms-batch
|
||||||
|
|
||||||
|
# واقعی
|
||||||
|
SMS_IMPORT_TARGETS=./prisma/khanegi-sms-targets.json \
|
||||||
|
SMS_IMPORT_ONLY_PILOT=1 \
|
||||||
|
npm run prisma:import-sms-batch
|
||||||
|
```
|
||||||
|
|
||||||
|
یا تکدامنه:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/khanegi-sms-import-one.sh DOMAIN 127.0.0.1 DB_USER 'DB_PASS' DB_NAME
|
||||||
|
bash deploy/khanegi-sms-import-one.sh DOMAIN 127.0.0.1 DB_USER 'DB_PASS' DB_NAME --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
بعد در UI سوپرادمین موجودی/تعرفه/بلاک را با پنل خانگی قبل از روشن کردن remote چک کن.
|
||||||
|
ایمپورت موفق `tenants.sms_synced_at` را هم پر میکند.
|
||||||
|
|
||||||
|
### ب-۴) روشن کردن remote فقط روی `.env` همان نسخه
|
||||||
|
|
||||||
|
مقدار `SMS_REMOTE_SHARED_SECRET` را از `/var/www/superadmin/api/.env` بردار، بعد روی سرور خانگی:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# مسیر server همان نسخه
|
||||||
|
bash /path/to/khanegi-sms-enable-remote.sh /var/www/CUSTOMER/server DOMAIN 'SHARED_SECRET'
|
||||||
|
# یا دستی:
|
||||||
|
# SUPERADMIN_SMS_BASE_URL=https://adm.montzar.ir/api/remote/sms
|
||||||
|
# SUPERADMIN_SMS_SHARED_SECRET=...
|
||||||
|
# SUPERADMIN_SMS_DOMAIN=DOMAIN
|
||||||
|
php artisan config:clear && php artisan config:cache
|
||||||
|
php artisan sms:superadmin-probe
|
||||||
|
```
|
||||||
|
|
||||||
|
### ب-۵) rollout بقیه
|
||||||
|
|
||||||
|
همان چرخه نسخه به نسخه (یا batch بدون `ONLY_PILOT`).
|
||||||
|
`skip` / `sms_panel=me` / بدون wallet را جدا نگه دار.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ج) نسخههایی که هنوز فعال نشدهاند
|
||||||
|
|
||||||
|
- جدول `sms_wallets` ندارند یا خالیاند → اسکریپت skip میکند
|
||||||
|
- `sms_panel=me` → skip
|
||||||
|
- برای آنها فعلاً کاری نکن تا خودت بگویی
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## کار موازی تا اعلام تو
|
||||||
|
|
||||||
|
تا وقتی بگویی «پنل همکار/خانگی لازم نیست»:
|
||||||
|
|
||||||
|
- تغییر در **سوپرادمین** → با باز کردن پنل محصول هم دیده میشود (و در MySQL محلی هم mirror میشود)
|
||||||
|
- تغییر در **پنل همکار/خانگی** → در سوپرادمین ذخیره میشود
|
||||||
|
- ارسال SMS → کسر از سوپرادمین + mirror محلی
|
||||||
|
|
||||||
|
بعد از اطمینان نهایی، پنل محصولات حذف میشود و فقط سوپرادمین میماند.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run on the Khanegi install for ONE sold version (its own .env).
|
||||||
|
# Never point multiple installs on the same server at the same SUPERADMIN_SMS_DOMAIN.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash deploy/khanegi-sms-enable-remote.sh /path/to/khanegi/server DOMAIN SHARED_SECRET
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_SERVER_DIR="${1:-}"
|
||||||
|
DOMAIN="${2:-}"
|
||||||
|
SHARED_SECRET="${3:-}"
|
||||||
|
BASE_URL="${SUPERADMIN_SMS_BASE_URL:-https://adm.montzar.ir/api/remote/sms}"
|
||||||
|
|
||||||
|
if [[ -z "$INSTALL_SERVER_DIR" || -z "$DOMAIN" || -z "$SHARED_SECRET" ]]; then
|
||||||
|
echo "Usage: $0 /path/to/khanegi/server DOMAIN SHARED_SECRET" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENV_FILE="$INSTALL_SERVER_DIR/.env"
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing .env: $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup="$ENV_FILE.bak.sms-sync.$(date +%Y%m%d%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$backup"
|
||||||
|
echo "Backup: $backup"
|
||||||
|
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
grep -vE '^(SUPERADMIN_SMS_BASE_URL|SUPERADMIN_SMS_SHARED_SECRET|SUPERADMIN_SMS_DOMAIN|SUPERADMIN_REMOTE_TOKEN)=' "$ENV_FILE" > "$tmp" || true
|
||||||
|
mv "$tmp" "$ENV_FILE"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# SuperAdmin SMS sync — per sold version (domain=$DOMAIN)"
|
||||||
|
echo "SUPERADMIN_SMS_BASE_URL=$BASE_URL"
|
||||||
|
echo "SUPERADMIN_SMS_SHARED_SECRET=$SHARED_SECRET"
|
||||||
|
echo "SUPERADMIN_SMS_DOMAIN=$DOMAIN"
|
||||||
|
} >> "$ENV_FILE"
|
||||||
|
|
||||||
|
cd "$INSTALL_SERVER_DIR"
|
||||||
|
php artisan config:clear
|
||||||
|
php artisan config:cache || true
|
||||||
|
|
||||||
|
if php artisan list --raw 2>/dev/null | grep -q '^sms:superadmin-probe'; then
|
||||||
|
php artisan sms:superadmin-probe
|
||||||
|
else
|
||||||
|
echo "Env written. Deploy code with sms:superadmin-probe, then run: php artisan sms:superadmin-probe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK: remote enabled for domain=$DOMAIN in $ENV_FILE"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run on SuperAdmin host. Imports one Khanegi version MySQL → SuperAdmin SmsAccount.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash deploy/khanegi-sms-import-one.sh DOMAIN DB_HOST DB_USER DB_PASSWORD DB_NAME [--apply]
|
||||||
|
#
|
||||||
|
# Default is dry-run. Pass --apply for real write.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="${1:-}"
|
||||||
|
DB_HOST="${2:-127.0.0.1}"
|
||||||
|
DB_USER="${3:-}"
|
||||||
|
DB_PASSWORD="${4:-}"
|
||||||
|
DB_NAME="${5:-}"
|
||||||
|
MODE="${6:-}"
|
||||||
|
|
||||||
|
if [[ -z "$DOMAIN" || -z "$DB_USER" || -z "$DB_NAME" ]]; then
|
||||||
|
echo "Usage: $0 DOMAIN DB_HOST DB_USER DB_PASSWORD DB_NAME [--apply]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
if [[ -d /var/www/superadmin/api ]]; then
|
||||||
|
API_DIR=/var/www/superadmin/api
|
||||||
|
elif [[ -f "$SCRIPT_DIR/../package.json" ]]; then
|
||||||
|
API_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
else
|
||||||
|
API_DIR="$(cd "$SCRIPT_DIR/../apps/api" && pwd)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$API_DIR"
|
||||||
|
|
||||||
|
export SMS_IMPORT_DOMAIN="$DOMAIN"
|
||||||
|
export SMS_IMPORT_PRODUCT=khanegi
|
||||||
|
export SMS_IMPORT_DB_HOST="$DB_HOST"
|
||||||
|
export SMS_IMPORT_DB_PORT=3306
|
||||||
|
export SMS_IMPORT_DB_USER="$DB_USER"
|
||||||
|
export SMS_IMPORT_DB_PASSWORD="$DB_PASSWORD"
|
||||||
|
export SMS_IMPORT_DB_DATABASE="$DB_NAME"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "--apply" ]]; then
|
||||||
|
unset SMS_IMPORT_DRY_RUN || true
|
||||||
|
echo "APPLY import for $DOMAIN"
|
||||||
|
else
|
||||||
|
export SMS_IMPORT_DRY_RUN=1
|
||||||
|
echo "DRY-RUN import for $DOMAIN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm run prisma:import-sms-from-product
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Convenience: inventory + write JSON on SuperAdmin host.
|
||||||
|
set -euo pipefail
|
||||||
|
cd /var/www/superadmin/api
|
||||||
|
npm run prisma:inventory-khanegi-sms
|
||||||
|
SMS_INVENTORY_FORMAT=json SMS_INVENTORY_OUT=/tmp/khanegi-sms-inventory.json \
|
||||||
|
npm run prisma:inventory-khanegi-sms
|
||||||
|
echo "JSON: /tmp/khanegi-sms-inventory.json"
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
"prisma:seed-khanegi-defaults": "ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts",
|
"prisma:seed-khanegi-defaults": "ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts",
|
||||||
"prisma:ensure-pilot-sms-tokens": "ts-node --transpile-only prisma/ensure-pilot-sms-tokens.ts",
|
"prisma:ensure-pilot-sms-tokens": "ts-node --transpile-only prisma/ensure-pilot-sms-tokens.ts",
|
||||||
"prisma:import-sms-from-product": "ts-node --transpile-only prisma/import-sms-from-product.ts",
|
"prisma:import-sms-from-product": "ts-node --transpile-only prisma/import-sms-from-product.ts",
|
||||||
|
"prisma:import-sms-batch": "ts-node --transpile-only prisma/import-sms-batch.ts",
|
||||||
|
"prisma:inventory-khanegi-sms": "ts-node --transpile-only prisma/inventory-khanegi-sms.ts",
|
||||||
"prisma:copy-sms-between-tenants": "ts-node --transpile-only prisma/copy-sms-between-tenants.ts",
|
"prisma:copy-sms-between-tenants": "ts-node --transpile-only prisma/copy-sms-between-tenants.ts",
|
||||||
"prisma:list-tenants": "ts-node --transpile-only prisma/list-tenants.ts"
|
"prisma:list-tenants": "ts-node --transpile-only prisma/list-tenants.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Batch-import SMS wallets for many sold versions from a targets JSON file.
|
||||||
|
*
|
||||||
|
* Each target is one Khanegi/Hamkar MySQL (one sold version). Never share one
|
||||||
|
* DB/env across multiple domains on the same host.
|
||||||
|
*
|
||||||
|
* SMS_IMPORT_TARGETS=./prisma/khanegi-sms-targets.example.json \
|
||||||
|
* SMS_IMPORT_DRY_RUN=1 \
|
||||||
|
* npm run prisma:import-sms-batch
|
||||||
|
*
|
||||||
|
* Target shape:
|
||||||
|
* [
|
||||||
|
* {
|
||||||
|
* "domain": "example.ir",
|
||||||
|
* "product": "khanegi",
|
||||||
|
* "pilot": true,
|
||||||
|
* "db": { "host": "127.0.0.1", "port": 3306, "user": "u", "password": "p", "database": "db" }
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
|
||||||
|
type DbTarget = {
|
||||||
|
host?: string;
|
||||||
|
port?: number | string;
|
||||||
|
user?: string;
|
||||||
|
password?: string;
|
||||||
|
database: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportTarget = {
|
||||||
|
domain: string;
|
||||||
|
product?: 'khanegi' | 'hamkar';
|
||||||
|
pilot?: boolean;
|
||||||
|
skip?: boolean;
|
||||||
|
db: DbTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
function env(name: string, fallback = '') {
|
||||||
|
return String(process.env[name] ?? fallback).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTargets(filePath: string): ImportTarget[] {
|
||||||
|
const abs = path.resolve(filePath);
|
||||||
|
if (!fs.existsSync(abs)) {
|
||||||
|
throw new Error(`Targets file not found: ${abs}`);
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(fs.readFileSync(abs, 'utf8')) as ImportTarget[];
|
||||||
|
if (!Array.isArray(raw)) throw new Error('Targets JSON must be an array');
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const targetsPath =
|
||||||
|
env('SMS_IMPORT_TARGETS') ||
|
||||||
|
path.join(__dirname, 'khanegi-sms-targets.example.json');
|
||||||
|
const dryRun = env('SMS_IMPORT_DRY_RUN') === '1';
|
||||||
|
const onlyPilot = env('SMS_IMPORT_ONLY_PILOT') === '1';
|
||||||
|
const onlyDomain = env('SMS_IMPORT_DOMAIN');
|
||||||
|
|
||||||
|
let targets = loadTargets(targetsPath).filter((t) => !t.skip);
|
||||||
|
if (onlyPilot) targets = targets.filter((t) => t.pilot);
|
||||||
|
if (onlyDomain) {
|
||||||
|
targets = targets.filter(
|
||||||
|
(t) => t.domain.toLowerCase() === onlyDomain.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targets.length) {
|
||||||
|
console.error('No targets to import.');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
targetsPath,
|
||||||
|
dryRun,
|
||||||
|
onlyPilot,
|
||||||
|
onlyDomain: onlyDomain || null,
|
||||||
|
count: targets.length,
|
||||||
|
domains: targets.map((t) => t.domain),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const script = path.join(__dirname, 'import-sms-from-product.ts');
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const product = target.product || 'khanegi';
|
||||||
|
const db = target.db || ({} as DbTarget);
|
||||||
|
if (!db.database) {
|
||||||
|
console.error(`SKIP ${target.domain}: missing db.database`);
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childEnv = {
|
||||||
|
...process.env,
|
||||||
|
SMS_IMPORT_DOMAIN: target.domain,
|
||||||
|
SMS_IMPORT_PRODUCT: product,
|
||||||
|
SMS_IMPORT_DRY_RUN: dryRun ? '1' : env('SMS_IMPORT_DRY_RUN'),
|
||||||
|
SMS_IMPORT_DB_HOST: String(db.host || '127.0.0.1'),
|
||||||
|
SMS_IMPORT_DB_PORT: String(db.port || 3306),
|
||||||
|
SMS_IMPORT_DB_USER: String(db.user || 'root'),
|
||||||
|
SMS_IMPORT_DB_PASSWORD: String(db.password ?? ''),
|
||||||
|
SMS_IMPORT_DB_DATABASE: String(db.database),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\n=== ${dryRun ? 'DRY ' : ''}IMPORT ${target.domain} (${product}) ===`);
|
||||||
|
const result = spawnSync(
|
||||||
|
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||||
|
['ts-node', '--transpile-only', script],
|
||||||
|
{
|
||||||
|
env: childEnv,
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(`FAILED: ${target.domain} (exit ${result.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. failed=${failed} / total=${targets.length}`);
|
||||||
|
if (failed) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -366,6 +366,11 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await prisma.tenant.update({
|
||||||
|
where: { id: tenant.id },
|
||||||
|
data: { smsSyncedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
action: 'sms.import.from_product',
|
action: 'sms.import.from_product',
|
||||||
@@ -381,6 +386,7 @@ async function main() {
|
|||||||
ledgerCount: ledgers.length,
|
ledgerCount: ledgers.length,
|
||||||
sendLogCount: sendLogs.length,
|
sendLogCount: sendLogs.length,
|
||||||
mysql: { host, database },
|
mysql: { host, database },
|
||||||
|
smsSyncedAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -390,6 +396,7 @@ async function main() {
|
|||||||
balanceRials: payload.balanceRials,
|
balanceRials: payload.balanceRials,
|
||||||
ledger: ledgers.length,
|
ledger: ledgers.length,
|
||||||
sendLogs: sendLogs.length,
|
sendLogs: sendLogs.length,
|
||||||
|
smsSyncedAt: true,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await conn.end();
|
await conn.end();
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Inventory Khanegi tenants for SMS sync rollout.
|
||||||
|
*
|
||||||
|
* Groups by nodeServer host (multiple versions may share one physical server).
|
||||||
|
* Prints TSV + summary JSON. Use on the SuperAdmin host:
|
||||||
|
*
|
||||||
|
* npm run prisma:inventory-khanegi-sms
|
||||||
|
* SMS_INVENTORY_FORMAT=json npm run prisma:inventory-khanegi-sms
|
||||||
|
* SMS_INVENTORY_OUT=/tmp/khanegi-sms-inventory.json npm run prisma:inventory-khanegi-sms
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { PrismaClient, ProductType } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function env(name: string, fallback = '') {
|
||||||
|
return String(process.env[name] ?? fallback).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFromUrl(url?: string | null) {
|
||||||
|
if (!url) return '(unknown-host)';
|
||||||
|
return (
|
||||||
|
String(url)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^https?:\/\//, '')
|
||||||
|
.split('/')[0]
|
||||||
|
.split(':')[0] || '(unknown-host)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncStatus =
|
||||||
|
| 'no_sms_account'
|
||||||
|
| 'imported'
|
||||||
|
| 'imported_marked'
|
||||||
|
| 'empty_default'
|
||||||
|
| 'inactive';
|
||||||
|
|
||||||
|
function classify(row: {
|
||||||
|
isActive: boolean;
|
||||||
|
hasAccount: boolean;
|
||||||
|
smsSyncedAt: Date | null;
|
||||||
|
balanceRials: number | null;
|
||||||
|
ledgerCount: number;
|
||||||
|
sendLogCount: number;
|
||||||
|
}): SyncStatus {
|
||||||
|
if (!row.isActive) return 'inactive';
|
||||||
|
if (!row.hasAccount) return 'no_sms_account';
|
||||||
|
if (row.smsSyncedAt) return 'imported_marked';
|
||||||
|
if (
|
||||||
|
(row.balanceRials ?? 0) === 0 &&
|
||||||
|
row.ledgerCount === 0 &&
|
||||||
|
row.sendLogCount === 0
|
||||||
|
) {
|
||||||
|
return 'empty_default';
|
||||||
|
}
|
||||||
|
return 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const format = (env('SMS_INVENTORY_FORMAT', 'tsv') || 'tsv').toLowerCase();
|
||||||
|
const outPath = env('SMS_INVENTORY_OUT');
|
||||||
|
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where: { product: ProductType.khanegi },
|
||||||
|
orderBy: [{ nodeServer: 'asc' }, { domain: 'asc' }],
|
||||||
|
include: {
|
||||||
|
smsAccount: {
|
||||||
|
include: {
|
||||||
|
_count: { select: { ledgers: true, sendLogs: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = tenants.map((t) => {
|
||||||
|
const acc = t.smsAccount;
|
||||||
|
const ledgerCount = acc?._count.ledgers ?? 0;
|
||||||
|
const sendLogCount = acc?._count.sendLogs ?? 0;
|
||||||
|
const balanceRials = acc?.balanceRials ?? null;
|
||||||
|
const status = classify({
|
||||||
|
isActive: t.isActive,
|
||||||
|
hasAccount: !!acc,
|
||||||
|
smsSyncedAt: t.smsSyncedAt,
|
||||||
|
balanceRials,
|
||||||
|
ledgerCount,
|
||||||
|
sendLogCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
nameFa: t.nameFa,
|
||||||
|
domain: t.domain,
|
||||||
|
domains: t.domains,
|
||||||
|
appUrl: t.appUrl,
|
||||||
|
adminUrl: t.adminUrl,
|
||||||
|
nodeServer: t.nodeServer,
|
||||||
|
serverHost: hostFromUrl(t.nodeServer),
|
||||||
|
isActive: t.isActive,
|
||||||
|
smsSyncedAt: t.smsSyncedAt?.toISOString() ?? null,
|
||||||
|
hasSmsAccount: !!acc,
|
||||||
|
balanceRials,
|
||||||
|
pricePerPartRials: acc?.pricePerPartRials ?? null,
|
||||||
|
isBlocked: acc?.isBlocked ?? null,
|
||||||
|
autoBlockBelowRials: acc?.autoBlockBelowRials ?? null,
|
||||||
|
ledgerCount,
|
||||||
|
sendLogCount,
|
||||||
|
syncStatus: status,
|
||||||
|
nextAction:
|
||||||
|
status === 'inactive'
|
||||||
|
? 'skip_inactive'
|
||||||
|
: status === 'no_sms_account' || status === 'empty_default'
|
||||||
|
? 'import_then_enable_remote'
|
||||||
|
: status === 'imported' || status === 'imported_marked'
|
||||||
|
? 'enable_remote_if_not_yet'
|
||||||
|
: 'review',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const byHost = new Map<string, typeof rows>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byHost.get(row.serverHost) || [];
|
||||||
|
list.push(row);
|
||||||
|
byHost.set(row.serverHost, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: rows.length,
|
||||||
|
byStatus: rows.reduce<Record<string, number>>((acc, r) => {
|
||||||
|
acc[r.syncStatus] = (acc[r.syncStatus] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
multiVersionHosts: [...byHost.entries()]
|
||||||
|
.filter(([, list]) => list.length > 1)
|
||||||
|
.map(([host, list]) => ({
|
||||||
|
host,
|
||||||
|
count: list.length,
|
||||||
|
domains: list.map((r) => r.domain),
|
||||||
|
})),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = { summary, rows };
|
||||||
|
|
||||||
|
if (outPath) {
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
console.error(`Wrote ${outPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
'serverHost',
|
||||||
|
'domain',
|
||||||
|
'nameFa',
|
||||||
|
'syncStatus',
|
||||||
|
'nextAction',
|
||||||
|
'balanceRials',
|
||||||
|
'pricePerPartRials',
|
||||||
|
'isBlocked',
|
||||||
|
'smsSyncedAt',
|
||||||
|
'ledgerCount',
|
||||||
|
'sendLogCount',
|
||||||
|
'appUrl',
|
||||||
|
'nodeServer',
|
||||||
|
].join('\t'),
|
||||||
|
);
|
||||||
|
for (const r of rows) {
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
r.serverHost,
|
||||||
|
r.domain,
|
||||||
|
r.nameFa,
|
||||||
|
r.syncStatus,
|
||||||
|
r.nextAction,
|
||||||
|
r.balanceRials ?? '',
|
||||||
|
r.pricePerPartRials ?? '',
|
||||||
|
r.isBlocked ?? '',
|
||||||
|
r.smsSyncedAt ?? '',
|
||||||
|
r.ledgerCount,
|
||||||
|
r.sendLogCount,
|
||||||
|
r.appUrl ?? '',
|
||||||
|
r.nodeServer,
|
||||||
|
].join('\t'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error(JSON.stringify(summary, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"domain": "k.montzar.ir",
|
||||||
|
"product": "khanegi",
|
||||||
|
"pilot": true,
|
||||||
|
"db": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 3306,
|
||||||
|
"user": "REPLACE_USER",
|
||||||
|
"password": "REPLACE_PASSWORD",
|
||||||
|
"database": "REPLACE_DATABASE"
|
||||||
|
},
|
||||||
|
"notes": "Pilot first. Put real MySQL of THIS version only — never a shared DB for all sites on the host."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"domain": "example-customer.ir",
|
||||||
|
"product": "khanegi",
|
||||||
|
"pilot": false,
|
||||||
|
"skip": true,
|
||||||
|
"db": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 3306,
|
||||||
|
"user": "REPLACE_USER",
|
||||||
|
"password": "REPLACE_PASSWORD",
|
||||||
|
"database": "REPLACE_DATABASE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -36,8 +36,11 @@ async function main() {
|
|||||||
domains: t.domains,
|
domains: t.domains,
|
||||||
appUrl: t.appUrl,
|
appUrl: t.appUrl,
|
||||||
nodeServer: t.nodeServer,
|
nodeServer: t.nodeServer,
|
||||||
|
smsSyncedAt: t.smsSyncedAt,
|
||||||
balanceRials: t.smsAccount?.balanceRials ?? null,
|
balanceRials: t.smsAccount?.balanceRials ?? null,
|
||||||
pricePerPartRials: t.smsAccount?.pricePerPartRials ?? null,
|
pricePerPartRials: t.smsAccount?.pricePerPartRials ?? null,
|
||||||
|
isBlocked: t.smsAccount?.isBlocked ?? null,
|
||||||
|
autoBlockBelowRials: t.smsAccount?.autoBlockBelowRials ?? null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
Reference in New Issue
Block a user