Initialize the API and add managed app banners.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:47 +03:30
co-authored by Cursor
commit b15fefe438
158 changed files with 25624 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
IMAGE_NAME="${IMAGE_NAME:-fabric-trade-api}"
IMAGE_TAG="${IMAGE_TAG:-latest}"
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
OUT_DIR="${OUT_DIR:-./dist-docker}"
OUT_TAR="${OUT_DIR}/${IMAGE_NAME}-${IMAGE_TAG}.tar"
NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}"
mkdir -p "$OUT_DIR"
echo "==> Building image locally: $FULL_IMAGE"
echo "==> npm registry: $NPM_REGISTRY"
docker build \
--build-arg "NPM_REGISTRY=${NPM_REGISTRY}" \
-t "$FULL_IMAGE" \
-f Dockerfile \
.
echo "==> Saving image to $OUT_TAR ..."
docker save "$FULL_IMAGE" -o "$OUT_TAR"
# فشرده‌سازی برای آپلود سریع‌تر
echo "==> Compressing..."
gzip -f -k "$OUT_TAR"
echo ""
echo "آماده:"
echo " $OUT_TAR"
echo " ${OUT_TAR}.gz"
ls -lh "$OUT_TAR" "${OUT_TAR}.gz"
echo ""
echo "آپلود روی سرور:"
echo " scp ${OUT_TAR}.gz root@SERVER:/opt/parche/server/"
echo ""
echo "روی سرور:"
echo " gunzip -f fabric-trade-api-latest.tar.gz"
echo " docker load -i fabric-trade-api-latest.tar"
echo " RUN_SEED=true docker compose -f docker-compose.image.yml up -d"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
MIRROR="${NPM_REGISTRY:-https://registry.npmmirror.com}"
echo "==> بیلد لوکال بدون Docker"
echo "==> registry: $MIRROR"
echo "==> node: $(node -v)"
npm config set registry "$MIRROR"
# Prisma postinstall باید اجازه اجرا داشته باشد
npm config set ignore-scripts false || true
if [ ! -d node_modules ]; then
echo "==> npm ci..."
npm ci --no-audit --no-fund
fi
# اگر اسکریپت‌های prisma بلاک شده باشند
npx --yes prisma generate
echo "==> nest/tsc build (با پاک کردن کش)..."
rm -rf dist tsconfig.build.tsbuildinfo
npx nest build
if [ ! -f dist/main.js ]; then
echo "==> nest build خروجی نداد؛ fallback به tsc..."
rm -rf dist tsconfig.build.tsbuildinfo
npx tsc -p tsconfig.build.json
fi
if [ ! -f dist/main.js ]; then
echo "ERROR: dist/main.js ساخته نشد"
ls -la dist || true
exit 1
fi
echo "==> OK: dist/main.js ($(wc -c < dist/main.js) bytes)"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
MIRROR="${NPM_REGISTRY:-https://registry.npmmirror.com}"
OUT="deploy-bundle.tar.gz"
PACK=".deploy-pack"
echo "==> بیلد + بسته‌بندی کامل (dist + node_modules)"
echo "==> node: $(node -v)"
npm config set registry "$MIRROR"
npm ci --no-audit --no-fund
DATABASE_URL="${DATABASE_URL:-postgresql://fabric:fabric123@localhost:5432/fabric_trade}" npx prisma generate
rm -rf dist tsconfig.build.tsbuildinfo
npx nest build
test -f dist/main.js
rm -rf "$PACK" /tmp/fabric-prod-pack
mkdir -p "$PACK" /tmp/fabric-prod-pack
cp package.json package-lock.json /tmp/fabric-prod-pack/
cp -a prisma /tmp/fabric-prod-pack/
cp prisma.config.ts /tmp/fabric-prod-pack/
(
cd /tmp/fabric-prod-pack
sed -i "s|https://registry.npmjs.org|${MIRROR}|g" package-lock.json || true
npm ci --omit=dev --no-audit --no-fund --registry="$MIRROR"
npm install prisma ts-node typescript --no-save --no-audit --no-fund --registry="$MIRROR"
DATABASE_URL="${DATABASE_URL:-postgresql://fabric:fabric123@localhost:5432/fabric_trade}" npx prisma generate
)
cp package.json package-lock.json "$PACK/"
cp -a prisma "$PACK/"
cp prisma.config.ts docker-entrypoint.sh "$PACK/"
cp -a dist "$PACK/"
cp -a /tmp/fabric-prod-pack/node_modules "$PACK/"
cp Dockerfile.bundle "$PACK/Dockerfile"
cp docker-compose.bundle.yml "$PACK/docker-compose.yml"
cp .env.example "$PACK/"
cat > "$PACK/.dockerignore" <<'EOF'
.git
.env
*.md
EOF
tar -czf "$OUT" -C "$PACK" .
ls -lh "$OUT"
echo ""
echo "فایل آماده: $(pwd)/$OUT"
echo "آپلود:"
echo " scp $(pwd)/$OUT root@SERVER:/opt/parche/"
echo "روی سرور:"
echo " mkdir -p /opt/parche/app && tar -xzf /opt/parche/deploy-bundle.tar.gz -C /opt/parche/app"
echo " cd /opt/parche/app && cp -n .env.example .env"
echo " RUN_SEED=true docker compose up -d --build"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# کلاینت Prisma را روی سیستم دولوپر می‌سازد و بسته‌بندی می‌کند.
# سرور نباید prisma generate بزند؛ روی هاست پروडاکشن دقایق طول می‌کشد.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT="prisma-client.tar.gz"
SRC="node_modules/.prisma/client"
if [ ! -d node_modules/prisma ]; then
echo "ERROR: node_modules نیست. اول npm ci بزن." >&2
exit 1
fi
# خروجی قبلی پاک شود تا engineهای بی‌استفاده در بسته نمانند
rm -rf "$SRC"
DATABASE_URL="${DATABASE_URL:-postgresql://build:build@localhost:5432/build?schema=public}" \
CHECKPOINT_DISABLE=1 ./node_modules/.bin/prisma generate
test -f "$SRC/libquery_engine-debian-openssl-3.0.x.so.node" \
|| { echo "ERROR: engine مربوط به debian-openssl-3.0.x ساخته نشد." >&2; exit 1; }
rm -f "$OUT"
tar -czf "$OUT" -C node_modules/.prisma client
ls -lh "$OUT"
+43
View File
@@ -0,0 +1,43 @@
/**
* ادمین‌های قبلی را پاک می‌کند و فقط یک ادمین می‌سازد.
* داخل کانتینر API:
* node scripts/reset-admin.mjs
* یا با DATABASE_URL محلی:
* node scripts/reset-admin.mjs
*/
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const PHONE = process.env.ADMIN_PHONE || '09128081392';
const PASSWORD = process.env.ADMIN_PASSWORD || '123456';
const FULL_NAME = process.env.ADMIN_NAME || 'مدیر سیستم';
async function main() {
const prisma = new PrismaClient();
try {
const passwordHash = await bcrypt.hash(PASSWORD, 10);
await prisma.$transaction([
prisma.adminUser.deleteMany({}),
prisma.adminUser.create({
data: {
id: 'usr-1',
fullName: FULL_NAME,
phone: PHONE,
email: 'admin@parche.ir',
passwordHash,
role: 'admin',
isActive: true,
},
}),
prisma.internalMessage.deleteMany({}),
]);
console.log(`Admin reset OK: ${PHONE} / ${PASSWORD}`);
} finally {
await prisma.$disconnect();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# روی سرور: یک دستور
set -euo pipefail
cd "$(dirname "$0")/.."
git pull --no-rebase
docker compose up -d --build
docker compose ps
echo "health: curl -s http://127.0.0.1:3000/api/health"
+530
View File
@@ -0,0 +1,530 @@
/**
* تست دود گردش کامل سفارش مطابق روال کارفرما:
* مشتری بدون دیدن قیمت سفارش می‌دهد، ادمین قیمت می‌گذارد و فاکتور می‌فرستد،
* مشتری روش تسویه را انتخاب می‌کند و مسیر نقدی/چکی تا تایید نهایی پیش می‌رود.
* اجرا: node scripts/smoke-order-flow.mjs
*/
const BASE = process.env.SMOKE_BASE_URL || 'http://127.0.0.1:3999/api';
const CUSTOMER_PHONE = '09121234567';
const ADMIN_PHONE = '09128081392';
const ADMIN_PASSWORD = '123456';
let passed = 0;
let failed = 0;
function check(name, condition, detail) {
if (condition) {
passed++;
console.log(` PASS ${name}`);
} else {
failed++;
console.log(` FAIL ${name}${detail ? ` → ${detail}` : ''}`);
}
}
async function api(path, { method = 'GET', body, token } = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
return { status: res.status, data };
}
function isoInDays(days) {
return new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
}
/** تصویر base64 بزرگ‌تر از سقف پیش‌فرض ۱۰۰KB اکسپرس */
function bigBase64(kb) {
return `data:image/jpeg;base64,${'A'.repeat(kb * 1024)}`;
}
function checkPayload(amount, days, extra = {}) {
return {
amount,
dueDate: isoInDays(days),
sayadId: '1234567890123456',
checkNumber: '123456',
ownerNationalId: '0012345678',
ownerFirstName: 'علی',
ownerLastName: 'رضایی',
bankName: 'ملی',
branchName: 'مرکزی',
mode: 'full',
...extra,
};
}
async function main() {
console.log(`\n== Smoke test @ ${BASE} ==\n`);
console.log('[1] احراز هویت');
const otp = await api('/auth/otp/request', {
method: 'POST',
body: { phone: CUSTOMER_PHONE },
});
check('درخواست OTP', otp.status === 201 || otp.status === 200, `status=${otp.status}`);
const code = otp.data?.debugCode ?? '1234';
check('کد OTP چهار رقمی است', /^\d{4}$/.test(String(code)), `code=${code}`);
const badVerify = await api('/auth/otp/verify', {
method: 'POST',
body: { phone: CUSTOMER_PHONE, code: String(code) === '0000' ? '1111' : '0000' },
});
check('کد اشتباه رد می‌شود', badVerify.status === 401, `status=${badVerify.status}`);
const verify = await api('/auth/otp/verify', {
method: 'POST',
body: { phone: CUSTOMER_PHONE, code: String(code) },
});
check('ورود مشتری', !!verify.data?.accessToken, JSON.stringify(verify.data).slice(0, 120));
const userToken = verify.data?.accessToken;
const adminLogin = await api('/admin/auth/login', {
method: 'POST',
body: { phone: ADMIN_PHONE, password: ADMIN_PASSWORD },
});
check('ورود ادمین', !!adminLogin.data?.accessToken, JSON.stringify(adminLogin.data).slice(0, 120));
const adminToken = adminLogin.data?.accessToken;
console.log('\n[2] جداسازی دسترسی');
const crossAccess = await api('/admin/orders', { token: userToken });
check('توکن مشتری به مسیر ادمین دسترسی ندارد', crossAccess.status === 403, `status=${crossAccess.status}`);
const custWithAdmin = await api('/orders', { token: adminToken });
check('توکن ادمین به مسیر مشتری دسترسی ندارد', custWithAdmin.status === 403, `status=${custWithAdmin.status}`);
const noToken = await api('/orders');
check('بدون توکن ۴۰۱', noToken.status === 401, `status=${noToken.status}`);
console.log('\n[3] سبد خرید بدون قیمت');
const companies = await api('/companies/public');
const company = companies.data?.[0];
check('لیست شرکت‌ها', !!company, JSON.stringify(companies.data).slice(0, 120));
check(
'سقف متراژ موثر برگردانده می‌شود',
typeof company?.maxMeteragePerInvoice === 'number' && company.maxMeteragePerInvoice > 0,
`max=${company?.maxMeteragePerInvoice}`,
);
const products = await api(`/products?companyId=${company.id}`, { token: userToken });
const product = (Array.isArray(products.data) ? products.data : products.data?.items)?.find(
(p) => p.companyId === company.id,
);
check('لیست محصولات', !!product, JSON.stringify(products.data).slice(0, 160));
const meterage = Math.max(product.minMeterage ?? 1, product.orderMultiplier ?? 1);
await api('/carts', { method: 'DELETE', token: userToken });
const addItem = await api('/carts/items', {
method: 'POST',
token: userToken,
body: { productId: product.id, meterage },
});
check('افزودن به سبد', addItem.status < 300, `status=${addItem.status} ${JSON.stringify(addItem.data).slice(0, 120)}`);
const cartItem = addItem.data?.items?.[0];
check('سبد قیمت واحد ندارد', cartItem?.unitPrice === undefined, `unitPrice=${cartItem?.unitPrice}`);
console.log('\n[4] ثبت سفارش بدون روش تسویه و بدون قیمت');
const cashOrder = await api('/orders', {
method: 'POST',
token: userToken,
body: { companyId: company.id, items: [{ productId: product.id, meterage }] },
});
check('ثبت سفارش', cashOrder.status < 300, `status=${cashOrder.status} ${JSON.stringify(cashOrder.data).slice(0, 200)}`);
const cashOrderId = cashOrder.data?.id;
check('وضعیت اولیه در انتظار بررسی', cashOrder.data?.status === 'awaiting_review', `status=${cashOrder.data?.status}`);
check('روش تسویه خالی است', cashOrder.data?.settlementType == null, `settlement=${cashOrder.data?.settlementType}`);
check('مبلغ برای مشتری پنهان است', cashOrder.data?.finalAmount == null && cashOrder.data?.totalAmount == null);
check('پرچم pricesVisible خاموش است', cashOrder.data?.pricesVisible === false, `flag=${cashOrder.data?.pricesVisible}`);
check(
'قیمت واحد آیتم‌ها پنهان است',
(cashOrder.data?.items ?? []).every((i) => i.unitPrice == null),
);
console.log('\n[5] اعتبارسنجی ورودی');
const extraField = await api('/orders', {
method: 'POST',
token: userToken,
body: { companyId: company.id, items: [{ productId: product.id, meterage }], bogusField: 'x' },
});
check('فیلد ناشناخته ۴۰۰ می‌دهد', extraField.status === 400, `status=${extraField.status}`);
const earlySettlement = await api(`/orders/${cashOrderId}/settlement`, {
method: 'POST',
token: userToken,
body: { settlementType: 'cash' },
});
check('انتخاب تسویه قبل از فاکتور رد می‌شود', earlySettlement.status === 400, `status=${earlySettlement.status}`);
const earlyReceipt = await api(`/orders/${cashOrderId}/cash-receipt`, {
method: 'POST',
token: userToken,
body: {
amount: 1000,
transferDate: isoInDays(0),
transferTime: '12:00',
trackingNumber: '1',
destinationAccountId: 'x',
},
});
check('رسید قبل از فاکتور رد می‌شود', earlyReceipt.status === 400, `status=${earlyReceipt.status}`);
console.log('\n[6] سطل‌های وضعیت ادمین');
const adminOrders = await api('/admin/orders', { token: adminToken });
const adminIds = (adminOrders.data ?? []).map((o) => o.id);
check('سفارش برای ادمین دیده می‌شود', adminIds.includes(cashOrderId));
const allowedTabs = new Set([
'pending_review',
'awaiting_customer',
'pending_check',
'pending_payment',
'needs_correction',
'check_correction',
'final_approval',
'shipped',
'delivered',
'cancelled',
'rejected',
'expired',
]);
const unknown = [...new Set((adminOrders.data ?? []).map((o) => o.adminStatus))].filter(
(s) => !allowedTabs.has(s),
);
check('همه وضعیت‌ها به تب معتبر نگاشت شده‌اند', unknown.length === 0, `unknown=${unknown.join(',')}`);
const filtered = await api('/admin/orders?status=pending_review', { token: adminToken });
check(
'فیلتر «در انتظار بررسی» سفارش را برمی‌گرداند',
(filtered.data ?? []).some((o) => o.id === cashOrderId),
`count=${(filtered.data ?? []).length}`,
);
console.log('\n[7] قیمت‌گذاری ادمین');
const detail = await api(`/admin/orders/${cashOrderId}`, { token: adminToken });
const items = detail.data?.items ?? [];
check('سفارش آیتم دارد', items.length > 0);
check(
'ادمین گذرهای مجاز را می‌بیند',
Array.isArray(detail.data?.allowedTransitions) && detail.data.allowedTransitions.includes('pricing'),
`transitions=${JSON.stringify(detail.data?.allowedTransitions)}`,
);
const earlyInvoice = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'awaiting_settlement' },
});
check('ارسال فاکتور بدون قیمت‌گذاری رد می‌شود', earlyInvoice.status === 400, `status=${earlyInvoice.status}`);
const pricing = await api(`/admin/orders/${cashOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: {
items: [{ id: items[0].id, confirmedMeterage: meterage, unitPrice: 100000, unitDiscount: 0 }],
shippingCost: 50000,
},
});
const expectedTotal = 100000 * meterage;
const expectedFinal = expectedTotal + 50000;
check('قیمت‌گذاری موفق', pricing.status < 300, `status=${pricing.status} ${JSON.stringify(pricing.data).slice(0, 150)}`);
check('جمع کل درست است', Math.abs((pricing.data?.totalAmount ?? 0) - expectedTotal) < 0.01, `total=${pricing.data?.totalAmount}`);
check('مبلغ نهایی = جمع + حمل', Math.abs((pricing.data?.finalAmount ?? 0) - expectedFinal) < 0.01, `final=${pricing.data?.finalAmount}`);
const stillHidden = await api(`/orders/${cashOrderId}`, { token: userToken });
check('قیمت تا ارسال فاکتور برای مشتری پنهان است', stillHidden.data?.finalAmount == null, `final=${stillHidden.data?.finalAmount}`);
const badDiscount = await api(`/admin/orders/${cashOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: { items: [{ id: items[0].id, unitPrice: 1000, unitDiscount: 5000 }] },
});
check('تخفیف بیشتر از قیمت رد می‌شود', badDiscount.status === 400, `status=${badDiscount.status}`);
console.log('\n[8] ارسال فاکتور برای مشتری');
await api(`/admin/orders/${cashOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: {
items: [{ id: items[0].id, confirmedMeterage: meterage, unitPrice: 100000, unitDiscount: 0 }],
shippingCost: 50000,
},
});
const invoice = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'awaiting_settlement', note: 'فاکتور نهایی شد' },
});
check('ارسال فاکتور', invoice.status < 300, `status=${invoice.status} ${JSON.stringify(invoice.data).slice(0, 150)}`);
const priced = await api(`/orders/${cashOrderId}`, { token: userToken });
check('مشتری مبلغ فاکتور را می‌بیند', Math.abs((priced.data?.finalAmount ?? 0) - expectedFinal) < 0.01, `final=${priced.data?.finalAmount}`);
check('pricesVisible روشن شد', priced.data?.pricesVisible !== false);
const illegal = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'delivered' },
});
check('گذر غیرمجاز بدون force رد می‌شود', illegal.status === 400, `status=${illegal.status}`);
const forceNoReason = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'delivered', force: true },
});
check('force بدون دلیل رد می‌شود', forceNoReason.status === 400, `status=${forceNoReason.status}`);
console.log('\n[9] اعلان مشتری');
const notifs = await api('/notifications', { token: userToken });
const invoiceNotif = (notifs.data ?? []).find((n) => n.event === 'invoice_priced');
check('اعلان صدور فاکتور ساخته شد', !!invoiceNotif, JSON.stringify(notifs.data)?.slice(0, 200));
check('اعلان به سفارش لینک دارد', invoiceNotif?.link === `/orders/${cashOrderId}`, `link=${invoiceNotif?.link}`);
const unread = await api('/notifications/unread-count', { token: userToken });
check('شمارنده خوانده‌نشده مثبت است', (unread.data?.count ?? 0) > 0, `count=${unread.data?.count}`);
if (invoiceNotif) {
const marked = await api(`/notifications/${invoiceNotif.id}/read`, { method: 'PATCH', token: userToken });
check('علامت خوانده‌شده', marked.status < 300 && marked.data?.count < (unread.data?.count ?? 0), JSON.stringify(marked.data));
}
const readAll = await api('/notifications/read-all', { method: 'PATCH', token: userToken });
check('خواندن همه', readAll.status < 300 && (readAll.data?.count ?? -1) === 0, JSON.stringify(readAll.data));
console.log('\n[10] مسیر نقدی');
const chooseCash = await api(`/orders/${cashOrderId}/settlement`, {
method: 'POST',
token: userToken,
body: { settlementType: 'cash' },
});
check('انتخاب تسویه نقدی', chooseCash.status < 300, `status=${chooseCash.status} ${JSON.stringify(chooseCash.data).slice(0, 150)}`);
check('وضعیت در انتظار پرداخت', chooseCash.data?.status === 'awaiting_cash_payment', `status=${chooseCash.data?.status}`);
const expiry = chooseCash.data?.cashInvoiceExpiresAt;
check('انقضای فاکتور در آینده است', !!expiry && new Date(expiry) > new Date(), `expiry=${expiry}`);
const accounts = await api(`/companies/${company.id}/bank-accounts`, { token: userToken });
const account = (accounts.data ?? [])[0];
check('حساب بانکی موجود', !!account, JSON.stringify(accounts.data).slice(0, 120));
const receipt = await api(`/orders/${cashOrderId}/cash-receipt`, {
method: 'POST',
token: userToken,
body: {
amount: expectedFinal,
transferDate: isoInDays(0),
transferTime: '12:00',
trackingNumber: '987654321',
destinationAccountId: account.id,
receiptImage: bigBase64(400),
},
});
check('ثبت رسید (تصویر ۴۰۰KB)', receipt.status < 300, `status=${receipt.status} ${JSON.stringify(receipt.data).slice(0, 200)}`);
const afterReceipt = await api(`/orders/${cashOrderId}`, { token: userToken });
check('وضعیت به تأیید پرداخت رفت', afterReceipt.data?.status === 'awaiting_cash_confirmation', `status=${afterReceipt.data?.status}`);
const receipts = await api('/admin/cash-payments', { token: adminToken });
const pendingReceipt = (receipts.data ?? []).find((r) => r.orderId === cashOrderId);
check('رسید در صف ادمین است', !!pendingReceipt, JSON.stringify(receipts.data)?.slice(0, 160));
const rejectReceipt = await api(`/admin/cash-payments/${pendingReceipt.id}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'rejected', note: 'مبلغ نامطابق' },
});
check('رد رسید', rejectReceipt.status < 300, `status=${rejectReceipt.status}`);
const afterReject = await api(`/orders/${cashOrderId}`, { token: userToken });
check(
'رد رسید مشتری را به مرحله پرداخت برمی‌گرداند',
afterReject.data?.status === 'awaiting_cash_payment',
`status=${afterReject.data?.status}`,
);
const approveReceipt = await api(`/admin/cash-payments/${pendingReceipt.id}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'approved' },
});
check('تأیید رسید', approveReceipt.status < 300, `status=${approveReceipt.status}`);
const afterApprove = await api(`/orders/${cashOrderId}`, { token: userToken });
check('وضعیت پرداخت تأییدشده', afterApprove.data?.status === 'cash_confirmed', `status=${afterApprove.data?.status}`);
const finalApprove = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'final_approved' },
});
check('تأیید نهایی سفارش نقدی', finalApprove.status < 300, `status=${finalApprove.status}`);
console.log('\n[11] مسیر چکی با حلقه اصلاح');
await api('/carts', { method: 'DELETE', token: userToken });
await api('/carts/items', {
method: 'POST',
token: userToken,
body: { productId: product.id, meterage },
});
const checkOrder = await api('/orders', {
method: 'POST',
token: userToken,
body: { companyId: company.id, items: [{ productId: product.id, meterage }] },
});
const checkOrderId = checkOrder.data?.id;
check('ثبت سفارش دوم', checkOrder.status < 300, `status=${checkOrder.status}`);
const checkDetail = await api(`/admin/orders/${checkOrderId}`, { token: adminToken });
const checkItem = checkDetail.data?.items?.[0];
await api(`/admin/orders/${checkOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: { items: [{ id: checkItem.id, confirmedMeterage: meterage, unitPrice: 100000 }] },
});
await api(`/admin/orders/${checkOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'awaiting_settlement' },
});
const chooseCheck = await api(`/orders/${checkOrderId}/settlement`, {
method: 'POST',
token: userToken,
body: { settlementType: 'check', checkMode: 'full' },
});
check('انتخاب تسویه چکی', chooseCheck.status < 300, `status=${chooseCheck.status} ${JSON.stringify(chooseCheck.data).slice(0, 150)}`);
check('وضعیت در حال ثبت چک', chooseCheck.data?.status === 'check_in_progress', `status=${chooseCheck.data?.status}`);
const submitChecks = await api(`/orders/${checkOrderId}/checks`, {
method: 'PATCH',
token: userToken,
body: {
checkMode: 'full',
checks: [
checkPayload(expectedTotal, 30, {
frontImage: bigBase64(300),
backImage: bigBase64(300),
}),
],
},
});
check('ارسال چک‌ها (بدنه ~۶۰۰KB)', submitChecks.status < 300, `status=${submitChecks.status} ${JSON.stringify(submitChecks.data).slice(0, 200)}`);
check('وضعیت بررسی چک', submitChecks.data?.status === 'awaiting_check_review', `status=${submitChecks.data?.status}`);
check('راس چک محاسبه شد', submitChecks.data?.checkRassDays === 30, `rass=${submitChecks.data?.checkRassDays}`);
const orderChecks = await api(`/admin/orders/${checkOrderId}/checks`, { token: adminToken });
const firstCheck = (orderChecks.data ?? [])[0];
check('چک برای ادمین دیده می‌شود', !!firstCheck, JSON.stringify(orderChecks.data)?.slice(0, 160));
const rejectCheck = await api(`/admin/checks/${firstCheck.id}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'rejected', note: 'تصویر ناخوانا' },
});
check('رد چک', rejectCheck.status < 300, `status=${rejectCheck.status}`);
const afterCheckReject = await api(`/orders/${checkOrderId}`, { token: userToken });
check(
'رد چک به اصلاح می‌رود نه بن‌بست',
afterCheckReject.data?.status === 'check_needs_fix',
`status=${afterCheckReject.data?.status}`,
);
const fixChecks = await api(`/orders/${checkOrderId}/checks`, {
method: 'PATCH',
token: userToken,
body: { checkMode: 'full', checks: [checkPayload(expectedTotal, 45)] },
});
check('اصلاح چک‌ها', fixChecks.status < 300, `status=${fixChecks.status} ${JSON.stringify(fixChecks.data).slice(0, 200)}`);
check('بازگشت به بررسی چک', fixChecks.data?.status === 'awaiting_check_review', `status=${fixChecks.data?.status}`);
const fixedChecks = await api(`/admin/orders/${checkOrderId}/checks`, { token: adminToken });
const approveCheck = await api(`/admin/checks/${fixedChecks.data[0].id}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'approved' },
});
check('تأیید چک', approveCheck.status < 300, `status=${approveCheck.status}`);
const afterCheckApprove = await api(`/orders/${checkOrderId}`, { token: userToken });
check('وضعیت تأیید چک‌ها', afterCheckApprove.data?.status === 'checks_approved', `status=${afterCheckApprove.data?.status}`);
console.log('\n[12] تنظیمات سیستم');
const settings = await api('/admin/settings', { token: adminToken });
check('خواندن تنظیمات', settings.status < 300 && typeof settings.data?.maxMeteragePerInvoice === 'number', JSON.stringify(settings.data)?.slice(0, 160));
const savedSettings = await api('/admin/settings', {
method: 'PATCH',
token: adminToken,
body: { smsPricingReady: false, maxMeteragePerInvoice: 6000 },
});
check('ذخیره تنظیمات', savedSettings.data?.maxMeteragePerInvoice === 6000 && savedSettings.data?.smsPricingReady === false, JSON.stringify(savedSettings.data)?.slice(0, 200));
const companyAfter = await api('/companies/public');
check(
'سقف متراژ شرکت از تنظیمات ارث می‌برد',
companyAfter.data?.[0]?.maxMeteragePerInvoice === 6000,
`max=${companyAfter.data?.[0]?.maxMeteragePerInvoice}`,
);
await api('/admin/settings', {
method: 'PATCH',
token: adminToken,
body: { smsPricingReady: true, maxMeteragePerInvoice: 5000 },
});
console.log('\n[13] دسترسی متقاطع بین مشتری‌ها');
const otherPhone = '09129999001';
await api('/auth/otp/request', {
method: 'POST',
body: { phone: otherPhone, firstName: 'تست', lastName: 'دوم', isRegister: true },
});
const otherVerify = await api('/auth/otp/verify', {
method: 'POST',
body: { phone: otherPhone, code: String(code) },
});
const otherToken = otherVerify.data?.accessToken;
check('ثبت‌نام مشتری دوم', !!otherToken, JSON.stringify(otherVerify.data).slice(0, 120));
if (otherToken) {
const foreign = await api(`/orders/${cashOrderId}`, { token: otherToken });
check('سفارش دیگری قابل خواندن نیست', foreign.status === 403, `status=${foreign.status}`);
const foreignSettlement = await api(`/orders/${checkOrderId}/settlement`, {
method: 'POST',
token: otherToken,
body: { settlementType: 'cash' },
});
check('انتخاب تسویه روی سفارش دیگری رد می‌شود', foreignSettlement.status === 403, `status=${foreignSettlement.status}`);
const foreignNotifs = await api('/notifications', { token: otherToken });
check('اعلان‌ها بین مشتری‌ها جدا است', (foreignNotifs.data ?? []).length === 0, `count=${(foreignNotifs.data ?? []).length}`);
}
console.log('\n[14] محافظت از ویرایش بعد از تحویل');
const forceDeliver = await api(`/admin/orders/${checkOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'delivered', force: true, reason: 'تحویل دستی برای تست' },
});
check('گذر دستی با دلیل پذیرفته می‌شود', forceDeliver.status < 300, `status=${forceDeliver.status} ${JSON.stringify(forceDeliver.data).slice(0, 150)}`);
const lateEdit = await api(`/orders/${checkOrderId}/checks`, {
method: 'PATCH',
token: userToken,
body: { checks: [checkPayload(1000, 10)] },
});
check('ویرایش چک بعد از تحویل رد می‌شود', lateEdit.status === 400, `status=${lateEdit.status}`);
const lateReceipt = await api(`/orders/${checkOrderId}/cash-receipt`, {
method: 'POST',
token: userToken,
body: {
amount: 1000,
transferDate: isoInDays(0),
transferTime: '12:00',
trackingNumber: '1',
destinationAccountId: account.id,
},
});
check('رسید نقدی روی سفارش چکی رد می‌شود', lateReceipt.status === 400, `status=${lateReceipt.status}`);
console.log(`\n== نتیجه: ${passed} موفق، ${failed} ناموفق ==\n`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('اجرای تست شکست خورد:', e);
process.exit(1);
});