Initial commit: Hedena Gold monorepo.

First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
s.esmaeili
2026-10-15 17:19:10 +03:30
commit ea86636027
1121 changed files with 401065 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env bash
#
# Auth smoke test for the routes closed in the security hotfix.
#
# For every route that was made non-public, asserts:
# - no token -> 401
# - low-privilege token -> 403
# - super-admin token -> anything but 401/403 (the guard let it through)
#
# And asserts the genuinely-public routes still answer 200, plus that
# GET /customers no longer leaks anything beyond the four login-lookup fields.
#
# Usage: API=http://localhost:3000/api bash scripts/auth-smoke.sh
# Needs the API running and the dev seed applied (npx prisma db seed).
set -uo pipefail
API="${API:-http://localhost:3000/api}"
SUPER_PHONE=09128081392; SUPER_PASS=09128081392 # ut 85, all rids
LOW_PHONE=09123334444; LOW_PASS=admin1234 # ut 88, 5 unrelated rids
pass=0; fail=0
ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); }
login() {
curl -s -X POST "$API/admin/auth/login" -H 'Content-Type: application/json' \
-d "{\"phone\":\"$1\",\"password\":\"$2\"}" |
sed -n 's/.*"api_token":"\([^"]*\)".*/\1/p'
}
code() { # method path [token]
local m=$1 p=$2 t=${3:-}
if [ -n "$t" ]; then
curl -s -o /dev/null -w '%{http_code}' -X "$m" "$API/$p" \
-H "Authorization: Bearer $t" -H 'Content-Type: application/json' -d '{}'
else
curl -s -o /dev/null -w '%{http_code}' -X "$m" "$API/$p" \
-H 'Content-Type: application/json' -d '{}'
fi
}
SUPER=$(login "$SUPER_PHONE" "$SUPER_PASS")
LOW=$(login "$LOW_PHONE" "$LOW_PASS")
[ -z "$SUPER" ] && { echo "super-admin login failed — is the seed applied?"; exit 1; }
[ -z "$LOW" ] && { echo "low-priv login failed — is the seed applied?"; exit 1; }
# route | required rid
CLOSED=(
"GET|accounting/customers/x/balance|wallet.list"
"POST|accounting/customers/x/reconcile|wallet.charge"
"POST|accounting/repayments/gold|document.create"
"POST|accounting/repayments/rial|document.create"
"POST|accounting/entries/x/confirm|document.list.confirm"
"PATCH|orders/x/status|order.list.sts.submit"
)
echo "Closed routes — no token must be 401:"
for row in "${CLOSED[@]}"; do
IFS='|' read -r m p _ <<<"$row"
c=$(code "$m" "$p")
[ "$c" = 401 ] && ok "$m $p -> 401" || bad "$m $p -> $c (want 401)"
done
echo "Closed routes — low-privilege token must be 403:"
for row in "${CLOSED[@]}"; do
IFS='|' read -r m p rid <<<"$row"
c=$(code "$m" "$p" "$LOW")
[ "$c" = 403 ] && ok "$m $p [$rid] -> 403" || bad "$m $p [$rid] -> $c (want 403)"
done
echo "Closed routes — super admin must get past the guard:"
for row in "${CLOSED[@]}"; do
IFS='|' read -r m p _ <<<"$row"
c=$(code "$m" "$p" "$SUPER")
case "$c" in
401|403) bad "$m $p -> $c (guard rejected a super admin)" ;;
*) ok "$m $p -> $c (through the guard)" ;;
esac
done
echo "Public routes must stay reachable:"
for p in "catalog/products" "pricing/latest?karatPerMille=750" "home/slider"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p")
[ "$c" = 200 ] && ok "GET $p -> 200" || bad "GET $p -> $c (want 200)"
done
# ---------------------------------------------------------------- stage 2 ---
# Customer session: real OTP, customer-scoped JWT, no id-bearing routes left.
echo "Old id-bearing customer routes must be gone entirely:"
for p in "customers" "customers/customer-dev-test" "customers/customer-dev-test/orders" "customers/customer-dev-test/ledger-entries"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p")
[ "$c" = 404 ] && ok "GET $p -> 404" || bad "GET $p -> $c (want 404)"
done
echo "Customer routes without a token must be 401:"
for p in "customer/me" "customer/me/orders" "customer/me/ledger-entries"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p")
[ "$c" = 401 ] && ok "GET $p -> 401" || bad "GET $p -> $c (want 401)"
done
# The dev seed's customer. Needs the API log to read the code back, since no SMS
# gateway is wired up — see CustomerAuthService.deliver.
CUST_PHONE=09128081392
otp_request() {
curl -s -o /dev/null -w '%{http_code}' -X POST "$API/customer/auth/request-otp" \
-H 'Content-Type: application/json' -d "{\"username\":\"$1\"}"
}
# Writes the JSON body to $BODY_FILE and prints the status code. The body goes
# to a file because this is called inside $(...), where a plain assignment would
# be lost along with the subshell.
BODY_FILE=$(mktemp)
trap 'rm -f "$BODY_FILE"' EXIT
otp_verify() { # mobile code -> prints http code; body lands in $BODY_FILE
curl -s -o "$BODY_FILE" -w '%{http_code}' -X POST "$API/customer/auth/verify-otp" \
-H 'Content-Type: application/json' -d "{\"username\":\"$1\",\"password\":\"$2\"}"
}
echo "OTP request:"
c=$(otp_request "$CUST_PHONE")
[ "$c" = 200 ] && ok "request-otp -> 200" || bad "request-otp -> $c (want 200)"
c=$(otp_request 09999999999)
[ "$c" = 200 ] && ok "unknown number answers 200 too (no enumeration)" \
|| bad "unknown number -> $c (want 200, identical to a known one)"
echo "OTP send throttle:"
c=$(otp_request "$CUST_PHONE")
[ "$c" = 429 ] && ok "immediate resend -> 429" || bad "immediate resend -> $c (want 429)"
echo "OTP verify:"
c=$(otp_verify "$CUST_PHONE" 0000)
[ "$c" = 401 ] && ok "wrong code -> 401" || bad "wrong code -> $c (want 401)"
# The real code only reachable from the server log; skip the rest when the
# caller did not point CODE_LOG at it.
if [ -n "${CODE_LOG:-}" ] && [ -f "$CODE_LOG" ]; then
CODE=$(grep -o "OTP for $CUST_PHONE: [0-9]*" "$CODE_LOG" | tail -1 | grep -o '[0-9]*$')
if [ -n "$CODE" ]; then
c=$(otp_verify "$CUST_PHONE" "$CODE")
if [ "$c" = 200 ]; then
ok "correct code -> 200"
CTOK=$(sed -n 's/.*"token":"\([^"]*\)".*/\1/p' "$BODY_FILE")
c=$(otp_verify "$CUST_PHONE" "$CODE")
[ "$c" = 401 ] && ok "same code reused -> 401 (single use)" \
|| bad "same code reused -> $c (want 401)"
echo "Customer token works on customer routes:"
for p in "customer/me" "customer/me/orders" "customer/me/ledger-entries"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $CTOK")
[ "$c" = 200 ] && ok "GET $p -> 200" || bad "GET $p -> $c (want 200)"
done
echo "Customer token must NOT work on admin routes:"
for p in "admin/auth/me" "admin/customers-omde/list" "admin/dashboard/overview"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $CTOK")
[ "$c" = 401 ] && ok "GET $p -> 401" || bad "GET $p -> $c (want 401)"
done
echo "Admin token must NOT work on customer routes:"
for p in "customer/me" "customer/me/orders"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $SUPER")
[ "$c" = 401 ] && ok "GET $p -> 401" || bad "GET $p -> $c (want 401)"
done
echo "No IDOR surface: /me* takes no id, and guessing one 404s:"
for p in "customer/me/customer-6" "customer/customer-6" "customers/customer-6"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $CTOK")
[ "$c" = 404 ] && ok "GET $p -> 404" || bad "GET $p -> $c (want 404)"
done
echo "/customer/me returns the token's own customer:"
who=$(curl -s "$API/customer/me" -H "Authorization: Bearer $CTOK" |
sed -n 's/.*"mobile":"\([^"]*\)".*/\1/p')
[ "$who" = "$CUST_PHONE" ] && ok "mobile = $who" \
|| bad "mobile = $who (want $CUST_PHONE)"
# ------------------------------------------------ SEC-02: orders ---
# POST /orders was public with customerId in the body, so anyone could
# place an order on anyone's account — and an order lands on that
# customer's gold debt and ledger.
echo "Orders require a customer token:"
c=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/orders" \
-H 'Content-Type: application/json' \
-d '{"customerId":"x","items":[{"productVariantId":"x","quantity":1,"wagePercent":7}]}')
[ "$c" = 401 ] && ok "POST orders without a token -> 401" \
|| bad "POST orders without a token -> $c (want 401)"
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/any-id")
[ "$c" = 401 ] && ok "GET orders/:id without a token -> 401" \
|| bad "GET orders/:id without a token -> $c (want 401)"
# A variant to order, and a customer that is NOT the token's owner.
VARIANT=$(curl -s "$API/catalog/products" |
sed -n 's/.*"variants":\[{"id":"\([^"]*\)".*/\1/p' | head -1)
if [ -n "$VARIANT" ]; then
echo "The body's customerId must be ignored in favour of the token:"
ORD=$(curl -s -X POST "$API/orders" -H "Authorization: Bearer $CTOK" \
-H 'Content-Type: application/json' \
-d "{\"customerId\":\"some-other-customer\",\"items\":[{\"productVariantId\":\"$VARIANT\",\"quantity\":1,\"wagePercent\":7}]}")
OWNER=$(printf '%s' "$ORD" | sed -n 's/.*"customerId":"\([^"]*\)".*/\1/p')
MY_ID=$(curl -s "$API/customer/me" -H "Authorization: Bearer $CTOK" |
sed -n 's/^{"id":"\([^"]*\)".*/\1/p')
[ -n "$OWNER" ] && [ "$OWNER" = "$MY_ID" ] \
&& ok "order landed on the token's customer ($OWNER), not the body's" \
|| bad "order landed on '$OWNER', expected '$MY_ID'"
# orderNumber is unique per order, so it identifies the row we just made
# without having to parse nested item ids out of the response.
ORDNUM=$(printf '%s' "$ORD" | sed -n 's/.*"orderNumber":"\([^"]*\)".*/\1/p')
MYORD=$(curl -s "$API/customer/me/orders" -H "Authorization: Bearer $CTOK" |
tr '{' '\n' | grep -F "\"orderNumber\":\"$ORDNUM\"" |
sed -n 's/^"id":"\([^"]*\)".*/\1/p' | head -1)
if [ -n "$MYORD" ]; then
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/$MYORD" -H "Authorization: Bearer $CTOK")
[ "$c" = 200 ] && ok "own order readable -> 200" || bad "own order -> $c (want 200)"
fi
# Someone else's order and a nonexistent one must be indistinguishable,
# or the status code itself confirms which ids exist.
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/definitely-not-an-order" \
-H "Authorization: Bearer $CTOK")
[ "$c" = 404 ] && ok "unknown order id -> 404" || bad "unknown order id -> $c (want 404)"
echo "Admin order read is admin-only:"
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/admin/any" -H "Authorization: Bearer $CTOK")
[ "$c" = 401 ] && ok "customer token on orders/admin/:id -> 401" \
|| bad "customer token on orders/admin/:id -> $c (want 401)"
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/admin/any" -H "Authorization: Bearer $SUPER")
case "$c" in
401|403) bad "admin token on orders/admin/:id -> $c (guard rejected an admin)" ;;
*) ok "admin token on orders/admin/:id -> $c (through the guard)" ;;
esac
else
printf ' \033[33mSKIP\033[0m no product variant available to order\n'
fi
else
bad "correct code -> $c (want 200)"
fi
else
printf ' \033[33mSKIP\033[0m no OTP line in %s yet\n' "$CODE_LOG"
fi
else
printf ' \033[33mSKIP\033[0m set CODE_LOG=/path/to/api.log to test the full OTP round trip\n'
fi
echo
echo "passed: $pass failed: $fail"
[ "$fail" -eq 0 ]
+106
View File
@@ -0,0 +1,106 @@
/**
* باندل تک‌فایل برای Docker (release/app.js) بعد از nest build.
* Prisma client + engines را در release/vendor می‌گذارد (نه node_modules تا gitignore نخورد).
*/
const esbuild = require('esbuild');
const {
existsSync,
mkdirSync,
rmSync,
cpSync,
realpathSync,
readdirSync,
} = require('fs');
const path = require('path');
// Only this engine ships to Docker; see apps/api/Dockerfile (bookworm/OpenSSL 3).
const RUNTIME_ENGINE = 'libquery_engine-debian-openssl-3.0.x.so.node';
const root = path.join(__dirname, '..');
const config = require(path.join(root, 'esbuild.release.config.cjs'));
const releaseDir = path.join(root, 'release');
const vendorDir = path.join(releaseDir, 'vendor');
function resolvePkg(name) {
return path.dirname(require.resolve(`${name}/package.json`, { paths: [root] }));
}
function copyPrismaIntoRelease() {
const prismaClientSrc = resolvePkg('@prisma/client');
const prismaClientDest = path.join(vendorDir, '@prisma', 'client');
mkdirSync(path.dirname(prismaClientDest), { recursive: true });
cpSync(prismaClientSrc, prismaClientDest, { recursive: true });
const candidates = [
path.join(root, 'node_modules', '.prisma', 'client'),
path.join(prismaClientSrc, '..', '.prisma', 'client'),
path.join(path.dirname(prismaClientSrc), '.prisma', 'client'),
path.join(prismaClientSrc, '..', '..', '.prisma', 'client'),
];
const prismaEngineSrc = candidates.find((p) => existsSync(p));
if (!prismaEngineSrc) {
throw new Error(
'Prisma generated client (.prisma/client) یافت نشد. ابتدا: pnpm --filter api exec prisma generate',
);
}
const prismaEngineDest = path.join(vendorDir, '.prisma', 'client');
mkdirSync(path.dirname(prismaEngineDest), { recursive: true });
cpSync(realpathSync(prismaEngineSrc), prismaEngineDest, { recursive: true });
// Drop engines for other platforms (local Windows dll, stale musl builds) so the
// committed artifact stays small.
for (const name of readdirSync(prismaEngineDest)) {
const isEngine = name.endsWith('.so.node') || name.endsWith('.dll.node');
if (isEngine && name !== RUNTIME_ENGINE) {
rmSync(path.join(prismaEngineDest, name), { force: true });
}
}
if (!existsSync(path.join(prismaEngineDest, RUNTIME_ENGINE))) {
throw new Error(
`${RUNTIME_ENGINE} تولید نشد. binaryTargets در prisma/schema.prisma را بررسی کنید.`,
);
}
}
async function main() {
const entry = config.entryPoints[0];
const fallbackEntry = path.join(root, 'dist', 'src', 'main.js');
if (!existsSync(entry)) {
if (existsSync(fallbackEntry)) {
config.entryPoints = [fallbackEntry];
} else {
console.error('dist/main.js یافت نشد. ابتدا اجرا کنید: pnpm --filter api build');
process.exit(1);
}
}
rmSync(releaseDir, { recursive: true, force: true });
mkdirSync(releaseDir, { recursive: true });
await esbuild.build(config);
copyPrismaIntoRelease();
// SQL migrations are read at runtime by src/prisma/migrate.ts
cpSync(path.join(root, 'prisma', 'migrations'), path.join(releaseDir, 'migrations'), {
recursive: true,
});
// Seeding runs as a separate process on a fresh database (src/prisma/bootstrap.ts)
await esbuild.build({
...config,
entryPoints: [path.join(root, 'prisma', 'seed.ts')],
outfile: path.join(releaseDir, 'seed.js'),
});
console.log('نوشت:', config.outfile);
console.log('Prisma client در release/vendor کپی شد.');
console.log('migrations و seed.js در release کپی شدند.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Wipe Nest emit output + stale TypeScript incremental caches.
* Incremental caches can skip emit after dist/ is deleted (empty dist, exit 0).
*/
const { rmSync, existsSync } = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const targets = [
path.join(root, 'dist'),
path.join(root, 'tsconfig.tsbuildinfo'),
path.join(root, 'tsconfig.build.tsbuildinfo'),
];
for (const target of targets) {
if (existsSync(target)) {
rmSync(target, { recursive: true, force: true });
console.log('removed', path.relative(root, target) || 'dist');
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Legacy system (MySQL `oapi`) → hedena-gold (PostgreSQL) data import.
*
* The rules live in docs/DATA-MIGRATION-MAP.md; every step cites its section.
*
* Usage (from apps/api):
*
* npx ts-node --transpile-only scripts/legacy-import/index.ts \
* --source "mysql://user:pass@127.0.0.1:3307/oapi" \
* --target "postgresql://user:pass@localhost:5432/hedenagold_import_test" \
* [--images "F:/legacy/storage" --upload-dir "./storage/uploads"] \
* [--only customers,ledger] [--report ./legacy-import-report.md] --yes
*
* Safety:
* - The source is only read. Point it at a COPY of production anyway.
* - --target is required and never taken from .env, so the dev database is
* never written by accident. Without --yes nothing is written; a target
* whose name does not contain "import" or "staging" also needs
* --confirm-db=<its exact name>.
* - Re-runnable: every written row is recorded in legacy_import.id_map, so a
* second run updates instead of duplicating, and a run that stops half way
* is finished by running it again.
* - Never writes Customer/Product accounting* columns, ProductVariant.imageId,
* or the Accounting* tables, and never deletes a ProductImage.
* - Writes go straight through Prisma, not through the API services, so no
* SMS is sent and nothing is queued to the accounting software.
*/
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { PrismaClient } from '@prisma/client';
import { IdMap, makeContext, openSource, type Ctx, type Options } from './lib/context';
import { admins, branches, cities, customers, experts, roles, workshops } from './steps/people';
import { brands, categories, products, variants } from './steps/catalog';
import { favorites, ledger, orders, preOrders } from './steps/commerce';
import { reconcile, reconcileSales } from './steps/reconcile';
/** Order matters: each step reads the ids the earlier ones mapped. */
const STEPS: [string, (ctx: Ctx) => Promise<void>][] = [
['cities', cities],
['roles', roles],
['branches', branches],
['admins', admins],
['experts', experts],
['workshops', workshops],
['customers', customers],
['brands', brands],
['categories', categories],
['products', products],
['variants', variants],
['orders', orders],
['ledger', ledger],
['favorites', favorites],
['preorders', preOrders],
['reconcile', reconcile],
['reconcile-sales', reconcileSales],
];
const ENTITIES = [
'city', 'ut', 'branch', 'admin', 'expert', 'workshop', 'customer', 'file', 'brand', 'category',
'product', 'pimage', 'variant', 'order', 'orderitem', 'ledger', 'preorder',
];
/** Columns the prep migrations added; the import refuses to run without them. */
const REQUIRED = [
['Customer', 'nationalCode'], ['Customer', 'birthDate'], ['Customer', 'cityNameRaw'],
['Customer', 'verificationRejectionReason'], ['CustomerFavorite', 'customerId'],
];
function parseArgs(argv: string[]) {
const a = new Map<string, string>();
for (let i = 0; i < argv.length; i++) {
const k = argv[i];
if (!k.startsWith('--')) continue;
const eq = k.indexOf('=');
if (eq > 0) a.set(k.slice(2, eq), k.slice(eq + 1));
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) a.set(k.slice(2), argv[++i]);
else a.set(k.slice(2), 'true');
}
return a;
}
const fail = (msg: string): never => {
console.error(`\n✖ ${msg}\n`);
process.exit(1);
};
async function main() {
const a = parseArgs(process.argv.slice(2));
const opt: Options = {
sourceUrl: a.get('source') ?? fail('--source <mysql url> is required'),
targetUrl: a.get('target') ?? fail('--target <postgres url> is required (never read from .env)'),
imagesDir: a.get('images'),
uploadDir: a.get('upload-dir'),
only: a.get('only') ? new Set(a.get('only')!.split(',').map((s) => s.trim())) : undefined,
reportPath: resolve(a.get('report') ?? 'legacy-import-report.md'),
};
if (opt.imagesDir && !opt.uploadDir) fail('--images needs --upload-dir (the target UPLOAD_DIR)');
if (opt.only) for (const s of opt.only) if (!STEPS.some(([n]) => n === s)) fail(`unknown step "${s}"`);
const db = new PrismaClient({ datasources: { db: { url: opt.targetUrl } } });
const src = openSource(opt.sourceUrl);
try {
/* preflight — read only */
const [{ db: dbName }] = await db.$queryRawUnsafe<{ db: string }[]>('SELECT current_database() AS db');
const [srcInfo] = await (await src.query('SELECT DATABASE() AS db, VERSION() AS v'))[0] as { db: string; v: string }[];
const missing: string[] = [];
for (const [t, c] of REQUIRED) {
const [{ n }] = await db.$queryRawUnsafe<{ n: bigint }[]>(
`SELECT COUNT(*) AS n FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`, t, c,
);
if (Number(n) === 0) missing.push(`${t}.${c}`);
}
const counts = {
customers: await db.customer.count(),
products: await db.product.count(),
ledger: await db.ledgerEntry.count(),
};
console.log(`source mysql ${srcInfo.v} · database ${srcInfo.db}`);
console.log(`target postgres · database ${dbName} · ${counts.customers} customers, ${counts.products} products, ${counts.ledger} ledger entries`);
if (missing.length) fail(`target is missing ${missing.join(', ')} — apply the prep migrations first`);
if (a.get('yes') !== 'true') {
console.log('\nNothing written. Re-run with --yes to import.');
return;
}
if (!/import|staging/i.test(dbName) && a.get('confirm-db') !== dbName) {
fail(`"${dbName}" does not look like a test or staging copy. To write to it anyway add --confirm-db=${dbName}`);
}
/* run */
await IdMap.ensureSchema(db);
const ctx = makeContext(opt, src, db);
for (const e of ENTITIES) await ctx.map.load(e);
for (const [name, step] of STEPS) {
if (opt.only && !opt.only.has(name)) continue;
const t0 = Date.now();
process.stdout.write(`▸ ${name} … `);
await step(ctx);
console.log(`${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
const md = ctx.report.render([
`- started: ${ctx.report.startedAt.toISOString()} · finished: ${new Date().toISOString()}`,
`- source: \`${srcInfo.db}\` (MySQL ${srcInfo.v}) · target: \`${dbName}\``,
`- steps: ${opt.only ? [...opt.only].join(', ') : 'all'}`,
`- images: ${opt.imagesDir ? `${opt.imagesDir} → ${opt.uploadDir}` : 'not copied (no --images)'}`,
]);
writeFileSync(opt.reportPath, md);
console.log(`\nreport: ${opt.reportPath}`);
} finally {
await src.end();
await db.$disconnect();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,327 @@
/**
* Shared state for one import run: both connections, the id map and the report.
*/
import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import { createPool, type Pool, type RowDataPacket } from 'mysql2/promise';
export interface Options {
sourceUrl: string;
targetUrl: string;
/** Legacy system's `storage/` folder holding the image files (optional). */
imagesDir?: string;
/** Target UPLOAD_DIR the files are copied into (required with imagesDir). */
uploadDir?: string;
/** Run only these steps (by name); all when unset. */
only?: Set<string>;
reportPath: string;
}
/* ───────────── report ───────────── */
interface Section {
counts: Map<string, number>;
notes: string[];
}
/**
* What the run did, step by step. Counts say how many rows went which way;
* notes are the things a person must look at. Rendered to markdown at the end.
*/
export class Report {
readonly startedAt = new Date();
private readonly sections = new Map<string, Section>();
private section(step: string): Section {
let s = this.sections.get(step);
if (!s) {
s = { counts: new Map(), notes: [] };
this.sections.set(step, s);
}
return s;
}
count(step: string, key: string, n = 1) {
const s = this.section(step);
s.counts.set(key, (s.counts.get(key) ?? 0) + n);
}
note(step: string, message: string) {
this.section(step).notes.push(message);
}
get(step: string, key: string): number {
return this.sections.get(step)?.counts.get(key) ?? 0;
}
render(header: string[]): string {
const out: string[] = ['# legacy (oapi) → hedena-gold import report', '', ...header, ''];
for (const [step, s] of this.sections) {
out.push(`## ${step}`, '');
if (s.counts.size) {
out.push('| | |', '|---|---:|');
for (const [k, v] of s.counts) out.push(`| ${k} | ${v} |`);
out.push('');
}
for (const n of s.notes) out.push(`- ${n}`);
if (s.notes.length) out.push('');
}
return out.join('\n');
}
}
/* ───────────── id map ───────────── */
/**
* Legacy id -> target id, per entity, persisted in the TARGET database under
* its own schema `legacy_import` — outside the `public` schema Prisma manages,
* so it is no app-schema change and drops cleanly after cutover
* (`DROP SCHEMA legacy_import CASCADE`).
*
* It is what makes a re-run safe (a mapped row is updated, never re-created)
* and what later redirects old links such as /product/123 (GO-LIVE-PLAN §7).
* `how` records whether the row was created by the import or matched to a row
* that already existed in the target (merge, §10.5).
*/
export type How = 'created' | 'matched' | 'merged';
export class IdMap {
private readonly cache = new Map<string, Map<string, { target: string; how: How }>>();
constructor(private readonly db: PrismaClient) {}
static async ensureSchema(db: PrismaClient) {
await db.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS legacy_import`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS legacy_import.id_map (
entity text NOT NULL,
legacy_id text NOT NULL,
target_id text NOT NULL,
how text NOT NULL,
mapped_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (entity, legacy_id)
)`);
}
async load(entity: string) {
const rows = await this.db.$queryRawUnsafe<{ legacy_id: string; target_id: string; how: How }[]>(
`SELECT legacy_id, target_id, how FROM legacy_import.id_map WHERE entity = $1`,
entity,
);
this.cache.set(entity, new Map(rows.map((r) => [r.legacy_id, { target: r.target_id, how: r.how }])));
}
get(entity: string, legacyId: unknown): string | undefined {
if (legacyId === null || legacyId === undefined) return undefined;
return this.cache.get(entity)?.get(String(legacyId))?.target;
}
how(entity: string, legacyId: unknown): How | undefined {
return this.cache.get(entity)?.get(String(legacyId))?.how;
}
async set(entity: string, legacyId: unknown, targetId: string, how: How) {
const key = String(legacyId);
await this.db.$executeRawUnsafe(
`INSERT INTO legacy_import.id_map (entity, legacy_id, target_id, how)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entity, legacy_id) DO UPDATE SET target_id = EXCLUDED.target_id, how = EXCLUDED.how`,
entity, key, targetId, how,
);
let m = this.cache.get(entity);
if (!m) {
m = new Map();
this.cache.set(entity, m);
}
m.set(key, { target: targetId, how });
}
async drop(entity: string, legacyId: unknown) {
await this.db.$executeRawUnsafe(
`DELETE FROM legacy_import.id_map WHERE entity = $1 AND legacy_id = $2`,
entity, String(legacyId),
);
this.cache.get(entity)?.delete(String(legacyId));
}
}
/* ───────────── upsert discipline ───────────── */
/**
* The one way a step writes a row (§10.5: this is a MERGE into a live target):
*
* - already mapped -> `update(id, how)`; the step decides what it may touch
* - matches a target row -> mapped as 'matched', then `update(id, 'matched')`
* - otherwise -> `create()`, mapped as 'created'
*
* A mapping whose target row has since been deleted is dropped and redone, so a
* re-run heals instead of failing on a dangling id.
*/
export async function upsert(
ctx: Ctx,
entity: string,
legacyId: unknown,
ops: {
exists: (id: string) => Promise<boolean>;
find: () => Promise<{ id: string } | null>;
create: () => Promise<{ id: string }>;
update: (id: string, how: How) => Promise<void>;
},
): Promise<{ id: string; how: How; isNew: boolean }> {
const mapped = ctx.map.get(entity, legacyId);
if (mapped) {
if (await ops.exists(mapped)) {
const how = ctx.map.how(entity, legacyId) ?? 'created';
await ops.update(mapped, how);
return { id: mapped, how, isNew: false };
}
await ctx.map.drop(entity, legacyId);
}
const found = await ops.find();
if (found) {
await ctx.map.set(entity, legacyId, found.id, 'matched');
await ops.update(found.id, 'matched');
return { id: found.id, how: 'matched', isNew: false };
}
const created = await ops.create();
await ctx.map.set(entity, legacyId, created.id, 'created');
return { id: created.id, how: 'created', isNew: true };
}
/**
* For a row that already existed in the target (`matched`): only the fields
* the target left empty are filled. The target's own values win — the owner
* confirmed its data is real (§10.5).
*/
export function fillEmpty<T extends Record<string, unknown>>(current: Record<string, unknown>, patch: T): Partial<T> {
const out: Partial<T> = {};
for (const [k, v] of Object.entries(patch)) {
const cur = current[k];
if ((cur === null || cur === undefined || cur === '') && v !== null && v !== undefined) {
(out as Record<string, unknown>)[k] = v;
}
}
return out;
}
/* ───────────── context ───────────── */
export interface Ctx {
src: Pool;
db: PrismaClient;
map: IdMap;
report: Report;
opt: Options;
/** A read-only query against the legacy MySQL. */
q<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
/** Public URL for a legacy `image` row, copying its file when a source dir is given. */
imageUrl(imageId: unknown, step: string): Promise<string | null>;
/**
* An `UploadedFile` row for a legacy image — what avatars point at
* (Customer/AdminUser.avatarFileId are FKs, not URLs). Mapped as 'file'.
*/
uploadedFile(imageId: unknown, purpose: string, step: string): Promise<string | null>;
}
const MIME: Record<string, string> = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf',
};
export function openSource(url: string): Pool {
return createPool({
uri: url,
connectionLimit: 4,
charset: 'utf8mb4',
// Timestamps come back as the wall-clock text legacy stored, and are
// converted from Tehran time explicitly (normalize.tehran). The session
// time zone is left at the server's own, which is what Laravel and the
// Navicat dump both used.
dateStrings: true,
supportBigNumbers: true,
bigNumberStrings: true,
});
}
export function makeContext(opt: Options, src: Pool, db: PrismaClient): Ctx {
const map = new IdMap(db);
const report = new Report();
const imageRows = new Map<string, string | null>();
const ctx: Ctx = {
src,
db,
map,
report,
opt,
async q<T>(sql: string, params: unknown[] = []) {
const [rows] = await src.query<RowDataPacket[]>(sql, params);
return rows as unknown as T[];
},
async imageUrl(imageId: unknown, step: string) {
if (imageId === null || imageId === undefined || imageId === '') return null;
const key = String(imageId);
if (!imageRows.has(key)) {
const [row] = await ctx.q<{ image: string | null }>('SELECT image FROM image WHERE id = ?', [key]);
imageRows.set(key, row?.image ? String(row.image).trim() : null);
}
const file = imageRows.get(key);
if (!file) {
report.count(step, 'image reference with no `image` row');
return null;
}
copyImage(ctx, file, step);
// Same shape the admin upload writes (admin-uploads.service.ts:61).
return `/uploads/${file}`;
},
async uploadedFile(imageId: unknown, purpose: string, step: string) {
if (imageId === null || imageId === undefined || imageId === '') return null;
// Resolve first even when mapped: resolving copies the file (see productImages).
const url = await ctx.imageUrl(imageId, step);
const mapped = map.get('file', imageId);
if (mapped) return mapped;
if (!url) return null;
const [row] = await ctx.q<{ original_name: string | null; ext: string | null }>(
'SELECT original_name, ext FROM image WHERE id = ?', [String(imageId)],
);
const file = url.slice('/uploads/'.length);
const ext = (row?.ext ?? file.split('.').pop() ?? '').toLowerCase().trim();
const src = ctx.opt.imagesDir ? join(ctx.opt.imagesDir, file) : null;
const created = await db.uploadedFile.create({
data: {
url,
originalName: (row?.original_name ?? '').trim() || file,
mimeType: MIME[ext] ?? 'application/octet-stream',
sizeBytes: src && existsSync(src) ? statSync(src).size : 0,
purpose,
},
});
await map.set('file', imageId, created.id, 'created');
return created.id;
},
};
return ctx;
}
const copied = new Set<string>();
function copyImage(ctx: Ctx, file: string, step: string) {
const { imagesDir, uploadDir } = ctx.opt;
if (!imagesDir || !uploadDir || copied.has(file)) return;
copied.add(file);
const from = join(imagesDir, file);
const to = join(uploadDir, file);
if (existsSync(to)) {
ctx.report.count('images', 'already in upload dir');
return;
}
if (!existsSync(from) || !statSync(from).isFile()) {
ctx.report.count('images', 'missing in legacy storage');
ctx.report.note('images', `missing file ${file} (referenced from ${step})`);
return;
}
mkdirSync(uploadDir, { recursive: true });
copyFileSync(from, to);
ctx.report.count('images', 'copied');
}
@@ -0,0 +1,204 @@
/**
* Value conversion from the legacy system's MySQL schema to the Prisma one.
*
* Every helper here is pure, so the rules can be read and tested in isolation.
* Each rule is the one recorded in docs/DATA-MIGRATION-MAP.md.
*/
import { Prisma } from '@prisma/client';
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
/** Persian/Arabic-Indic digits -> ASCII. Legacy did the same (Utility::faTOen). */
export function latinDigits(s: string): string {
return s.replace(/[۰-۹٠-٩]/g, (d) => {
const i = PERSIAN_DIGITS.indexOf(d);
return String(i >= 0 ? i : ARABIC_DIGITS.indexOf(d));
});
}
/**
* Persian text as stored: trimmed, single spaces, and Arabic ي/ك folded to
* Persian ی/ک. Empty -> null, so '' never reaches a nullable target column.
*/
export function text(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v)
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/\s+/g, ' ')
.trim();
return s === '' ? null : s;
}
/**
* Comparison key for matching names across systems. Also drops the zero-width
* non-joiner, which legacy typed inconsistently ("مدیر اصلی" vs "مدیراصلی").
* The ي/ك fold alone raised exact city matches from 32 to 72 (§10.4).
*/
export function matchKey(v: unknown): string | null {
const t = text(v);
return t === null ? null : t.replace(/‌/g, '').replace(/\s/g, '').toLowerCase();
}
/** Mobile as the login key: ASCII digits only, or null. */
export function mobile(v: unknown): string | null {
if (v === null || v === undefined) return null;
const d = latinDigits(String(v)).replace(/\D/g, '');
return d === '' ? null : d;
}
function num(v: unknown): number {
if (v === null || v === undefined || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* Legacy stores money and weight as `double` (§4). Going through toFixed at the
* target scale drops the binary noise (12.3 is really 12.30000000000000071…)
* exactly as Decimal(14,4) would, so a reconciliation can compare like with like.
*/
export const grams = (v: unknown) => new Prisma.Decimal(num(v).toFixed(4));
export const rial = (v: unknown) => new Prisma.Decimal(num(v).toFixed(2));
export const percent = (v: unknown) => new Prisma.Decimal(num(v).toFixed(3));
export const isZero = (v: unknown) => Math.abs(num(v)) < 0.00005;
export const toNumber = num;
/** Integer or null — for tinyint flags and ids that may arrive as strings. */
export function int(v: unknown): number | null {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isInteger(n) ? n : null;
}
/* ───────────── time ───────────── */
/**
* The Laravel app ran with timezone 'Asia/Tehran' (henza_server config/app.php:70),
* so every timestamp is Tehran wall-clock time. Read as a UTC instant it would
* be 3:30 (4:30 under the pre-2022 summer time) off, moving evening documents
* to the next day on a statement. The ICU zone rules know Iran's historical
* DST, so the offset is taken from them per instant rather than hard-coded.
*/
const tehranFormat = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
function tehranOffsetMs(utcMs: number): number {
const p: Record<string, string> = {};
for (const part of tehranFormat.formatToParts(new Date(utcMs))) p[part.type] = part.value;
const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
return asUtc - utcMs;
}
const WALL = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2}))?/;
/** 'YYYY-MM-DD HH:MM:SS' in Tehran wall-clock time -> the UTC instant. */
export function tehran(v: unknown): Date | null {
if (v === null || v === undefined || v === '') return null;
const m = WALL.exec(String(v));
if (!m || m[1] === '0000') return null;
const wall = Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0));
// Two passes: the offset at the guessed instant can differ across a DST edge.
let utc = wall - tehranOffsetMs(wall);
utc = wall - tehranOffsetMs(utc);
return new Date(utc);
}
/**
* Birthdays (owner ruling 2026-10-07, §10.7): of 915 set values only 541 are
* real. Dropped: the sign-up default (birth date == sign-up date, 190 rows) and
* the impossible ones (a time of day, or born after 2008; 184 rows). A
* birthday is a calendar date, so it is kept as UTC midnight of that date.
*/
export function birthday(birth: unknown, signup: unknown): Date | null {
const b = WALL.exec(String(birth ?? ''));
if (!b) return null;
if ((b[4] ?? '00') !== '00' || (b[5] ?? '00') !== '00' || (b[6] ?? '00') !== '00') return null;
if (+b[1] > 2008 || +b[1] < 1900) return null;
const s = WALL.exec(String(signup ?? ''));
if (s && s[1] === b[1] && s[2] === b[2] && s[3] === b[3]) return null;
return new Date(Date.UTC(+b[1], +b[2] - 1, +b[3]));
}
/* ───────────── Jalali dates ───────────── */
/**
* `cashes.recive_date` is a varchar holding a Jalali date ("1403/05/12"; 88 of
* 89 values start with 14). This is the standard jalaali-js arithmetic
* (Borkowski), inlined so the tool needs no extra dependency.
*
* Its integer division TRUNCATES toward zero. Math.floor is not a substitute:
* g2d's (gm - 8) / 6 is negative for March, and flooring it shifted every
* converted date by a year.
*/
const div = (a: number, b: number) => Math.trunc(a / b);
const mod = (a: number, b: number) => a - Math.trunc(a / b) * b;
function jalCal(jy: number) {
const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178];
const gy = jy + 621;
let leapJ = -14;
let jp = breaks[0];
let jump = 0;
for (let i = 1; i < breaks.length; i++) {
const jm = breaks[i];
jump = jm - jp;
if (jy < jm) break;
leapJ += div(jump, 33) * 8 + div(mod(jump, 33), 4);
jp = jm;
}
const n = jy - jp;
leapJ += div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
return { gy, march: 20 + leapJ - leapG };
}
function g2d(gy: number, gm: number, gd: number) {
const d =
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) +
div(153 * mod(gm + 9, 12) + 2, 5) +
gd - 34840408;
return d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
}
function d2g(jdn: number) {
let j = 4 * jdn + 139361631;
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
const i = div(mod(j, 1461), 4) * 5 + 308;
const gd = div(mod(i, 153), 5) + 1;
const gm = mod(div(i, 153), 12) + 1;
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
return { gy, gm, gd };
}
/** Jalali y/m/d -> Gregorian y/m/d. */
export function jalaliToGregorian(jy: number, jm: number, jd: number) {
const r = jalCal(jy);
const jdn = g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
return d2g(jdn);
}
/**
* A free-text Jalali date ("1403/5/12", "۱۴۰۳-۰۵-۱۲") -> UTC midnight of the
* same day, or null when it does not parse. Callers keep the raw text too.
*/
export function jalaliDate(v: unknown): Date | null {
const s = latinDigits(String(v ?? '')).trim();
const m = /^(1[34]\d{2})\D(\d{1,2})\D(\d{1,2})$/.exec(s);
if (!m) return null;
const jm = +m[2];
const jd = +m[3];
if (jm < 1 || jm > 12 || jd < 1 || jd > 31) return null;
const g = jalaliToGregorian(+m[1], jm, jd);
return new Date(Date.UTC(g.gy, g.gm - 1, g.gd));
}
@@ -0,0 +1,241 @@
/**
* Catalogue: brands, categories, products, variants and product images.
* Rules: docs/DATA-MIGRATION-MAP.md §9, §10.5.
*
* Which rows: every live product/variant, PLUS every soft-deleted variant (and
* its product) that a live order line still points at. 473 of the 610 live
* order lines reference a deleted variant; OrderItem.productVariantId is
* required, so without those the order history would lose most of its lines.
* They come over with isActive = false, so the shop never shows them.
*
* ProductVariant.imageId is never written (PROTECTED.md §B), and no
* ProductImage is ever deleted: the variant FK is onDelete SetNull, so a
* delete-and-recreate would silently clear every admin-picked variant image.
*/
import { ProductImageRole, Prisma } from '@prisma/client';
import { fillEmpty, upsert, type Ctx } from '../lib/context';
import { grams, int, isZero, matchKey, percent, tehran, text } from '../lib/normalize';
type Row = Record<string, unknown>;
const LIVE_ORDER_LINES = `
SELECT oi.variant_id FROM order_items oi
JOIN orders o ON o.id = oi.order_id AND o.deleted_at IS NULL
WHERE oi.deleted_at IS NULL AND oi.variant_id IS NOT NULL`;
export async function brands(ctx: Ctx) {
const S = 'brands';
const rows = await ctx.q<Row>(
`SELECT * FROM product_brand WHERE deleted_at IS NULL
OR id IN (SELECT brand_id FROM product WHERE brand_id IS NOT NULL)`,
);
const existing = await ctx.db.brand.findMany();
for (const r of rows) {
const title = text(r.title);
if (!title) continue;
const res = await upsert(ctx, 'brand', r.id, {
exists: async (id) => !!(await ctx.db.brand.findUnique({ where: { id } })),
find: async () => existing.find((b) => matchKey(b.title) === matchKey(title)) ?? null,
create: () => ctx.db.brand.create({ data: { title } }),
update: async (id, how) => {
if (how === 'created') await ctx.db.brand.update({ where: { id }, data: { title } });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'matched/updated');
}
}
export async function categories(ctx: Ctx) {
const S = 'categories';
const rows = await ctx.q<Row>('SELECT * FROM category_collection WHERE deleted_at IS NULL');
const existing = await ctx.db.category.findMany();
for (const r of rows) {
const title = text(r.title);
if (!title) continue;
const type = int(r.type) ?? 1;
// The port's mapping (admin-categories.service.ts:75-80): image, icon,
// meta -> metaTag, body_html -> description.
const data = {
title,
type,
imageUrl: await ctx.imageUrl(r.image_id ?? r.image_id_id, S),
iconUrl: await ctx.imageUrl(r.icon_id, S),
metaTag: text(r.meta),
description: text(r.body_html),
};
const res = await upsert(ctx, 'category', r.id, {
exists: async (id) => !!(await ctx.db.category.findUnique({ where: { id } })),
// Same title in the same tree (site / app) is the same category.
find: async () => existing.find((c) => matchKey(c.title) === matchKey(title) && c.type === type) ?? null,
create: () => ctx.db.category.create({ data }),
update: async (id, how) => {
if (how === 'created') {
await ctx.db.category.update({ where: { id }, data });
} else {
const cur = await ctx.db.category.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, data);
if (Object.keys(patch).length) await ctx.db.category.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated');
}
// Second pass, once every category has an id: the parent links.
for (const r of rows) {
const id = ctx.map.get('category', r.id);
if (!id || r.parent_id_id === null) continue;
const parentId = ctx.map.get('category', r.parent_id_id);
if (!parentId) {
ctx.report.count(S, 'parent not migrated (deleted in legacy) — left at top level');
continue;
}
if (ctx.map.how('category', r.id) !== 'matched') {
await ctx.db.category.update({ where: { id }, data: { parentId } });
}
}
}
export async function products(ctx: Ctx) {
const S = 'products';
const rows = await ctx.q<Row>(
`SELECT * FROM product
WHERE deleted_at IS NULL
OR id IN (SELECT v.product_id_id FROM products_productvarient v WHERE v.id IN (${LIVE_ORDER_LINES}))`,
);
// Legacy links products to categories many-to-many (category_collect) and
// keeps TWO trees: type 1 = site, type 2 = app (admin-categories.service.ts:12).
// Every product with more than one category (1,250) is simply in both trees.
// The target keeps one categoryId, which the app shows and links to
// (user-app SingleProduct.vue), so the APP category is the one kept;
// the site one only when the product has no app category.
const links = await ctx.q<Row>(
`SELECT cc.product_id_id AS pid, cc.collection_id_id AS cid, c.type AS ctype FROM category_collect cc
JOIN category_collection c ON c.id = cc.collection_id_id AND c.deleted_at IS NULL
WHERE cc.deleted_at IS NULL ORDER BY cc.position, cc.id`,
);
const chosenCategory = new Map<string, { cid: string; app: boolean }>();
const categoryCount = new Map<string, number>();
for (const l of links) {
const pid = String(l.pid);
const app = int(l.ctype) === 2;
categoryCount.set(pid, (categoryCount.get(pid) ?? 0) + 1);
const cur = chosenCategory.get(pid);
if (!cur || (app && !cur.app)) chosenCategory.set(pid, { cid: String(l.cid), app });
}
for (const r of rows) {
const title = text(r.title) ?? `محصول ${String(r.id)}`;
const deleted = r.deleted_at !== null;
const code = text(r.product_code);
const data = {
title,
description: text(r.body_html),
categoryId: ctx.map.get('category', chosenCategory.get(String(r.id))?.cid) ?? null,
brandId: ctx.map.get('brand', r.brand_id) ?? null,
productCode: code,
showType: int(r.show_type) ?? 3,
isActive: !deleted && int(r.is_active) === 1,
};
const chosen = chosenCategory.get(String(r.id));
if (!chosen) ctx.report.count(S, 'no live category');
else if ((categoryCount.get(String(r.id)) ?? 0) > 1) ctx.report.count(S, `in several legacy categories — ${chosen.app ? 'app' : 'site'} category kept`);
if (int(r.disabled) === 1) ctx.report.count(S, 'legacy `disabled` = 1 (not represented; port ignores it too)');
const res = await upsert(ctx, 'product', r.id, {
exists: async (id) => !!(await ctx.db.product.findUnique({ where: { id } })),
find: async () => (code ? ctx.db.product.findUnique({ where: { productCode: code }, select: { id: true } }) : null),
create: () => ctx.db.product.create({ data: { ...data, createdAt: tehran(r.created_at) ?? undefined } }),
update: async (id, how) => {
// Never accountingStock* (D27): not in `data`.
if (how === 'created') {
await ctx.db.product.update({ where: { id }, data });
} else {
const cur = await ctx.db.product.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, data);
if (Object.keys(patch).length) await ctx.db.product.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, `${res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated'}${deleted ? ' (deleted in legacy, kept inactive for order history)' : ''}`);
if (res.how !== 'matched') await productImages(ctx, r, res.id);
}
}
/**
* The port's image roles (admin-products.service.ts:185-188):
* indicator_id -> FEATURED, mobile_pic -> FEATURED_MOBILE; the gallery is
* products_productimage. Only for products the import created — a product the
* target already had keeps exactly the images it has.
*/
async function productImages(ctx: Ctx, p: Row, productId: string) {
const S = 'product images';
const add = async (key: string, imageId: unknown, role: ProductImageRole, sortOrder: number) => {
// Resolved even when the row exists, because resolving is what copies the
// file: a first run without --images followed by one with it must still
// bring the files over.
const url = await ctx.imageUrl(imageId, S);
if (!url || ctx.map.get('pimage', key)) return;
const img = await ctx.db.productImage.create({ data: { productId, url, role, sortOrder } });
await ctx.map.set('pimage', key, img.id, 'created');
ctx.report.count(S, role);
};
await add(`featured:${String(p.id)}`, p.indicator_id_id, ProductImageRole.FEATURED, 0);
await add(`mobile:${String(p.id)}`, p.mobile_pic_id, ProductImageRole.FEATURED_MOBILE, 0);
const gallery = await ctx.q<Row>(
'SELECT id, image_id FROM products_productimage WHERE deleted_at IS NULL AND product_id = ? ORDER BY id',
[p.id],
);
let i = 1;
for (const g of gallery) await add(`gallery:${String(g.id)}`, g.image_id, ProductImageRole.GALLERY, i++);
}
export async function variants(ctx: Ctx) {
const S = 'variants';
const rows = await ctx.q<Row>(
`SELECT * FROM products_productvarient
WHERE deleted_at IS NULL OR id IN (${LIVE_ORDER_LINES})`,
);
for (const r of rows) {
const productId = ctx.map.get('product', r.product_id_id);
if (!productId) {
ctx.report.count(S, 'skipped: product not migrated');
continue;
}
const deleted = r.deleted_at !== null;
let sku = text(r.barcode);
if (sku) {
const clash = await ctx.db.productVariant.findUnique({ where: { sku }, select: { id: true } });
if (clash && clash.id !== ctx.map.get('variant', r.id)) {
ctx.report.count(S, 'barcode already used by another variant — sku left empty');
sku = null;
}
}
// `Inventory` (capital I in the source column, §9) is legacy's stock count;
// `qty` is what the port reads, so that is what is carried.
const data: Prisma.ProductVariantUncheckedUpdateInput = {
productId,
title: text(r.title) ?? '-',
sku,
rashWeightGrams: grams(r.grams),
defaultWagePercent: percent(r.percentage_pay),
qty: int(r.qty) ?? 0,
isPreOrder: int(r.is_pre_order) === 1,
isActive: !deleted && int(r.is_active) === 1,
// imageId: deliberately absent — PROTECTED.md §B.
};
if (!isZero(r.rial_pay)) ctx.report.count(S, 'rial charge (rial_pay) not carried — no target field');
const res = await upsert(ctx, 'variant', r.id, {
exists: async (id) => !!(await ctx.db.productVariant.findUnique({ where: { id } })),
find: async () => (sku ? ctx.db.productVariant.findUnique({ where: { sku }, select: { id: true } }) : null),
create: () => ctx.db.productVariant.create({ data: data as Prisma.ProductVariantUncheckedCreateInput }),
update: async (id, how) => {
if (how === 'created') await ctx.db.productVariant.update({ where: { id }, data });
},
});
ctx.report.count(S, `${res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (left as is)' : 'updated'}${deleted ? ' (deleted in legacy, inactive)' : ''}`);
}
}
@@ -0,0 +1,429 @@
/**
* Orders, the ledger, favourites and custom orders.
* Rules: docs/DATA-MIGRATION-MAP.md §7, §8, §10, §10.7.
*/
import {
LedgerCurrency,
LedgerDirection,
LedgerSourceType,
OrderChannel,
OrderStatus,
PreOrderStatus,
Prisma,
} from '@prisma/client';
import { upsert, type Ctx } from '../lib/context';
import { grams, int, jalaliDate, percent, rial, tehran, text, toNumber } from '../lib/normalize';
type Row = Record<string, unknown>;
const D = Prisma.Decimal;
/** Order::$STS (Order.php:66-74). 7 «کنسل شده» is commented out in legacy and absent from the data. */
const ORDER_STATUS: Record<number, OrderStatus> = {
0: OrderStatus.SUSPENDED,
1: OrderStatus.PLACED,
2: OrderStatus.ADMIN_APPROVED,
3: OrderStatus.SOURCING,
4: OrderStatus.PREPARING,
5: OrderStatus.DELIVERED_TO_AGENT,
6: OrderStatus.DELIVERED_TO_CUSTOMER,
};
/* ───────────── orders ───────────── */
export async function orders(ctx: Ctx) {
const S = 'orders';
// Wholesale only. Retail `orders_orders` is deferred by the owner (§7.2) and
// must not be mapped with this status table: its 7 means «تحویل مشتری».
const rows = await ctx.q<Row>('SELECT * FROM orders WHERE deleted_at IS NULL');
for (const r of rows) {
const customerId = ctx.map.get('customer', r.customer_id);
if (!customerId) {
ctx.report.count(S, 'skipped: customer not migrated');
ctx.report.note(S, `legacy order #${String(r.id)} skipped: its customer #${String(r.customer_id)} was not migrated (status ${String(r.sts)})`);
continue;
}
const sts = int(r.sts);
const status = sts !== null ? ORDER_STATUS[sts] : undefined;
if (!status) {
ctx.report.count(S, 'skipped: unknown status');
ctx.report.note(S, `legacy order #${String(r.id)} has status ${String(r.sts)}, which has no mapping`);
continue;
}
const orderNumber = `H-${String(r.id)}`;
// `orders.reagent_id` is the expert the order was credited to when it was
// placed (never moved afterwards) — carried as is, NOT re-derived from the
// customer's current referrer (owner ruling 2026-10-08). It names a
// Customer row in legacy: an expert maps to their AdminUser; anyone else
// (one customer, 17 orders from 2022) is kept as the referrer customer.
let salesAgentId: string | null = null;
let referrerCustomerId: string | null = null;
if (r.reagent_id !== null) {
salesAgentId = ctx.map.get('expert', r.reagent_id) ?? null;
if (!salesAgentId) referrerCustomerId = ctx.map.get('customer', r.reagent_id) ?? null;
if (!salesAgentId && !referrerCustomerId) {
ctx.report.count(S, 'placing expert not migrated — left empty');
ctx.report.note(S, `legacy order #${String(r.id)}: its reagent #${String(r.reagent_id)} was not migrated`);
} else {
ctx.report.count(S, salesAgentId ? 'credited to its legacy expert' : 'credited to its legacy referrer customer');
}
}
const data = {
salesAgentId,
referrerCustomerId,
customerId,
channel: OrderChannel.WHOLESALE,
status,
// `input` is what legacy debits (CustomerController::updateRemain) and
// equals `w` on all 300 live orders; `wage` is the making-charge part of it.
totalGoldWeightGrams: grams(r.input),
wageWeightGrams: grams(r.wage),
flatRialCharge: rial(r.rial_input),
// Legacy 0 «معلق» is the over-credit-limit hold.
isSuspendedOverCreditLimit: sts === 0,
// Legacy records no delivery time; the last change of a delivered order is the best available.
deliveredAt: sts === 6 ? tehran(r.updated_at) : null,
};
const res = await upsert(ctx, 'order', r.id, {
exists: async (id) => !!(await ctx.db.order.findUnique({ where: { id } })),
find: () => ctx.db.order.findUnique({ where: { orderNumber }, select: { id: true } }),
create: () => ctx.db.order.create({ data: { ...data, orderNumber, createdAt: tehran(r.created_at) ?? undefined } }),
update: async (id, how) => {
if (how === 'created') await ctx.db.order.update({ where: { id }, data });
},
});
ctx.report.count(S, `${res.isNew ? 'created' : 'updated'} — ${status}`); }
await orderItems(ctx);
}
async function orderItems(ctx: Ctx) {
const S = 'order lines';
const rows = await ctx.q<Row>(
`SELECT oi.* FROM order_items oi JOIN orders o ON o.id = oi.order_id AND o.deleted_at IS NULL
WHERE oi.deleted_at IS NULL`,
);
for (const r of rows) {
const orderId = ctx.map.get('order', r.order_id);
if (!orderId) continue;
const productVariantId = ctx.map.get('variant', r.variant_id);
if (!productVariantId) {
ctx.report.count(S, 'skipped: variant not migrated');
ctx.report.note(S, `legacy order line #${String(r.id)} (order #${String(r.order_id)}) lost: variant #${String(r.variant_id)} not found`);
continue;
}
const data = {
orderId,
productVariantId,
quantity: int(r.qty) ?? 1,
rashWeightGramsSnapshot: grams(r.rash_weight),
wagePercentSnapshot: percent(r.percent),
discountPercentSnapshot: percent(r.discount),
computedWeightGrams: grams(r.w),
flatRialCharge: rial(r.rial_pay),
};
const res = await upsert(ctx, 'orderitem', r.id, {
exists: async (id) => !!(await ctx.db.orderItem.findUnique({ where: { id } })),
find: async () => null,
create: () => ctx.db.orderItem.create({ data }),
update: async (id) => {
await ctx.db.orderItem.update({ where: { id }, data });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'updated');
// §7.4: the target keeps one snapshot; legacy's audit pair is lost.
if (toNumber(r.original_rash_weight) !== toNumber(r.rash_weight) || toNumber(r.original_percent) !== toNumber(r.percent)) {
ctx.report.count(S, 'weight or wage edited after ordering (original_* not carried)');
}
}
}
/* ───────────── ledger ───────────── */
/** Cash::$TYPES (Major/Cash.php:56-62). Type 9 «سفارش» is handled from `orders`. */
const CASH_SOURCE: Record<number, LedgerSourceType> = {
1: LedgerSourceType.REPAYMENT_MELTED_GOLD,
2: LedgerSourceType.REPAYMENT_TRANSFER,
3: LedgerSourceType.REPAYMENT_CHEQUE,
4: LedgerSourceType.REPAYMENT_CASH,
};
/** Grams per mesghal. Legacy's price_per_gram is per mesghal (CashController.php:68). */
const MESGHAL = 4.331802;
/**
* Legacy debt is COMPUTED, never posted (CustomerController::updateRemain):
*
* remain = Σ orders.input (sts > 4) − Σ cashes.value (confirmed, type 1–4)
* rial_remain = Σ orders.rial_input (sts > 4)
*
* so the ledger is rebuilt from exactly those rows. Recomputing that formula on
* the 2026-10-04 dump reproduces the stored balance for 1,590 of 1,591
* customers to 0.0001 g (§10.7). Consequences:
* - `cashes` type 9 («سفارش») is NOT carried: it mirrors an order, and the
* order already posts that debt. Both would count it twice.
* - Rials paid (types 3/4 `price`) never reduce rial debt; a cash payment is
* converted to gold (`value`) and reduces the gold debt. Kept as is.
* - A balance the records cannot explain (customer #125: −9.8951 g, set by
* hand after its only document was deleted) is carried as an explicit
* ADJUSTMENT entry, so the customer sees the same number (owner, 2026-10-07).
*/
export async function ledger(ctx: Ctx) {
const S = 'ledger';
const entry = async (key: string, data: Prisma.LedgerEntryUncheckedCreateInput) => {
const res = await upsert(ctx, 'ledger', key, {
exists: async (id) => !!(await ctx.db.ledgerEntry.findUnique({ where: { id } })),
find: async () => null,
create: () => ctx.db.ledgerEntry.create({ data }),
update: async (id) => {
await ctx.db.ledgerEntry.update({ where: { id }, data });
},
});
return res.id;
};
const signed = (amount: number) => ({
direction: amount >= 0 ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
amount: grams(Math.abs(amount)),
});
// Orders delivered to the agent or the customer carry the debt.
const delivered = await ctx.q<Row>('SELECT * FROM orders WHERE deleted_at IS NULL AND sts > 4');
for (const o of delivered) {
const customerId = ctx.map.get('customer', o.customer_id);
const orderId = ctx.map.get('order', o.id);
if (!customerId || !orderId) continue;
const at = tehran(o.updated_at) ?? undefined;
if (toNumber(o.input) !== 0) {
await entry(`order:${String(o.id)}:gold`, {
customerId, orderId, ...signed(toNumber(o.input)),
currency: LedgerCurrency.GOLD_GRAM, sourceType: LedgerSourceType.ORDER_STOCK,
confirmed: true, confirmedAt: at, createdAt: at,
note: 'بدهی وزنی سفارش — انتقال از سیستم قدیم',
});
ctx.report.count(S, 'order debt (gold)');
}
if (toNumber(o.rial_input) !== 0) {
const amt = toNumber(o.rial_input);
await entry(`order:${String(o.id)}:rial`, {
customerId, orderId,
direction: amt >= 0 ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
amount: rial(Math.abs(amt)),
currency: LedgerCurrency.RIAL, sourceType: LedgerSourceType.ORDER_STOCK,
confirmed: true, confirmedAt: at, createdAt: at,
note: 'بدهی ریالی سفارش — انتقال از سیستم قدیم',
});
ctx.report.count(S, 'order debt (rial)');
}
}
const cashes = await ctx.q<Row>('SELECT * FROM cashes WHERE deleted_at IS NULL');
for (const c of cashes) {
const type = int(c.type);
if (type === 9) {
ctx.report.count(S, 'cash type 9 (order mirror) skipped — debt comes from the order');
continue;
}
const sourceType = type !== null ? CASH_SOURCE[type] : undefined;
if (!sourceType) {
ctx.report.count(S, 'skipped: unknown document type');
ctx.report.note(S, `legacy document #${String(c.id)} has type ${String(c.type)}`);
continue;
}
const customerId = ctx.map.get('customer', c.customer_id);
if (!customerId) {
ctx.report.count(S, 'skipped: customer not migrated');
continue;
}
// A repayment is a CREDIT; a negative `value` (4 transfers) is a debit.
const value = toNumber(c.value);
const received = jalaliDate(c.recive_date);
const confirmed = int(c.confirmed) === 1;
const ppg = toNumber(c.price_per_gram);
await entry(`cash:${String(c.id)}`, {
customerId,
currency: LedgerCurrency.GOLD_GRAM,
direction: value >= 0 ? LedgerDirection.CREDIT : LedgerDirection.DEBIT,
amount: grams(Math.abs(value)),
sourceType,
confirmed,
confirmedAt: confirmed ? tehran(c.updated_at) : null,
postedByAdminUserId: ctx.map.get('admin', c.user_id) ?? null,
karatPerMille: int(c.ayar),
labName: text(c.lab),
hallmark: text(c.ang),
accountName: text(c.account_name),
bankName: text(c.bank_name),
transferTo: text(c.to),
originalValue: c.original_value === null ? null : grams(c.original_value),
priceRial: c.price === null ? null : rial(c.price),
pricePerGramRialAtEntry: ppg ? rial(ppg / MESGHAL) : null,
receivedAt: received,
// The raw Jalali text is kept in the note when it did not parse.
note: [text(c.title), text(c.note), !received && text(c.recive_date) ? `تاریخ دریافت: ${text(c.recive_date)}` : null]
.filter(Boolean).join(' — ') || null,
createdAt: tehran(c.created_at) ?? undefined,
});
ctx.report.count(S, `document ${sourceType}${confirmed ? '' : ' (unconfirmed — not in the balance)'}`);
}
await balances(ctx);
}
/**
* Per migrated customer: compare what the import posted with the balance legacy
* stored, carry any real difference as one ADJUSTMENT entry, then set the
* cached balance to the sum of ALL confirmed entries (the rule every other
* ledger path keeps: confirmed DEBIT adds, confirmed CREDIT subtracts).
*/
async function balances(ctx: Ctx) {
const S = 'balances';
const legacy = await ctx.q<Row>('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL');
const stored = new Map<string, { gold: number; rial: number }>();
for (const r of legacy) {
const target = ctx.map.get('customer', r.id);
if (!target) continue;
const cur = stored.get(target) ?? { gold: 0, rial: 0 };
cur.gold += toNumber(r.remain);
cur.rial += toNumber(r.rial_remain);
stored.set(target, cur);
}
const importedIds = new Set<string>();
for (const row of await ctx.db.$queryRawUnsafe<{ target_id: string }[]>(
`SELECT target_id FROM legacy_import.id_map WHERE entity = 'ledger' AND legacy_id NOT LIKE 'adjust:%'`,
)) importedIds.add(row.target_id);
// Up to this a difference is the double → Decimal(14,4) rounding of §4, not a
// real gap — inclusive: the first run posted two −0.0001 "adjustments" that
// were nothing but rounding, and would have shown on those statements.
const TOLERANCE = 0.0001;
for (const [customerId, want] of stored) {
const entries = await ctx.db.ledgerEntry.findMany({ where: { customerId, confirmed: true } });
const sum = (currency: LedgerCurrency, onlyImported: boolean) =>
entries
.filter((e) => e.currency === currency && (!onlyImported || importedIds.has(e.id)))
.reduce((acc, e) => (e.direction === LedgerDirection.DEBIT ? acc.add(e.amount) : acc.sub(e.amount)), new D(0));
for (const [currency, wanted, label] of [
[LedgerCurrency.GOLD_GRAM, want.gold, 'gold'],
[LedgerCurrency.RIAL, want.rial, 'rial'],
] as const) {
const key = `adjust:${customerId}:${label}`;
const diff = new D(wanted.toFixed(4)).sub(sum(currency, true));
const existing = ctx.map.get('ledger', key);
if (diff.abs().lte(TOLERANCE)) {
if (!diff.isZero()) ctx.report.count(S, `rounding-only difference (${label}, ≤ ${TOLERANCE}) — no adjustment`);
if (existing) {
await ctx.db.ledgerEntry.deleteMany({ where: { id: existing } });
await ctx.map.drop('ledger', key);
}
continue;
}
const data: Prisma.LedgerEntryUncheckedCreateInput = {
customerId,
currency,
direction: diff.gt(0) ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
amount: currency === LedgerCurrency.RIAL ? new D(diff.abs().toFixed(2)) : new D(diff.abs().toFixed(4)),
sourceType: LedgerSourceType.ADJUSTMENT,
confirmed: true,
confirmedAt: new Date(),
note: 'تراز انتقالی از سیستم قدیم — مانده‌ی ذخیره‌شده در سیستم قدیم با اسناد قابل بازسازی نبود',
};
if (existing) await ctx.db.ledgerEntry.update({ where: { id: existing }, data });
else {
const created = await ctx.db.ledgerEntry.create({ data });
await ctx.map.set('ledger', key, created.id, 'created');
}
ctx.report.count(S, `opening-balance adjustment (${label})`);
ctx.report.note(S, `customer ${customerId}: ${label} adjustment ${diff.toString()} so the balance equals legacy's stored ${wanted.toFixed(4)}`);
}
// The cached balance = Σ every confirmed entry, the target's own and the imported ones.
const fresh = await ctx.db.ledgerEntry.findMany({ where: { customerId, confirmed: true } });
const total = (currency: LedgerCurrency) =>
fresh
.filter((e) => e.currency === currency)
.reduce((acc, e) => (e.direction === LedgerDirection.DEBIT ? acc.add(e.amount) : acc.sub(e.amount)), new D(0));
const before = await ctx.db.customer.findUniqueOrThrow({ where: { id: customerId }, select: { goldDebtGrams: true, fiatDebtRial: true } });
const gold = total(LedgerCurrency.GOLD_GRAM);
const rialSum = total(LedgerCurrency.RIAL);
if (!before.goldDebtGrams.eq(gold) || !before.fiatDebtRial.eq(rialSum)) {
await ctx.db.customer.update({ where: { id: customerId }, data: { goldDebtGrams: gold, fiatDebtRial: rialSum } });
ctx.report.count(S, 'cached balance set from the ledger');
}
}
}
/* ───────────── favourites ───────────── */
/**
* The wholesale app's favourites are `major_favorites` (ApiMobileController
* :73-465, model Major\Favorite) — 3,305 rows. `fav` (251) belongs to the
* retail storefront (Customer\FavController, guard api-singleCustomer); its
* customer_id points at retail logins, so it is deliberately NOT read here.
*/
export async function favorites(ctx: Ctx) {
const S = 'favorites';
const rows = await ctx.q<Row>(
'SELECT customer_id, product_id, MIN(created_at) AS created_at FROM major_favorites WHERE deleted_at IS NULL GROUP BY customer_id, product_id',
);
const data: Prisma.CustomerFavoriteCreateManyInput[] = [];
const seen = new Set<string>();
for (const r of rows) {
const customerId = ctx.map.get('customer', r.customer_id);
const productId = ctx.map.get('product', r.product_id);
if (!customerId) { ctx.report.count(S, 'skipped: customer not migrated'); continue; }
if (!productId) { ctx.report.count(S, 'skipped: product deleted in legacy'); continue; }
const k = `${customerId}|${productId}`;
if (seen.has(k)) { ctx.report.count(S, 'duplicate collapsed (merged customers)'); continue; }
seen.add(k);
data.push({ customerId, productId, createdAt: tehran(r.created_at) ?? undefined });
}
const res = await ctx.db.customerFavorite.createMany({ data, skipDuplicates: true });
ctx.report.count(S, 'created', res.count);
ctx.report.count(S, 'already present', data.length - res.count);
}
/* ───────────── custom orders ───────────── */
/** CustomOrder::$STS_EN (CustomOrder.php:41-47) maps one-to-one. */
const PREORDER_STATUS: Record<number, PreOrderStatus> = {
1: PreOrderStatus.PLACED,
2: PreOrderStatus.ADMIN_APPROVED,
3: PreOrderStatus.WORKSHOP_APPROVED,
4: PreOrderStatus.READY_TO_SHIP,
5: PreOrderStatus.DELIVERED,
6: PreOrderStatus.CANCELLED,
};
export async function preOrders(ctx: Ctx) {
const S = 'custom orders';
const rows = await ctx.q<Row>('SELECT * FROM custom_orders WHERE deleted_at IS NULL');
for (const r of rows) {
const customerId = ctx.map.get('customer', r.customer_id);
if (!customerId) { ctx.report.count(S, 'skipped: customer not migrated'); continue; }
const sumText = text(r.sum);
const data = {
customerId,
cityId: ctx.map.get('city', r.city_id) ?? null,
workshopId: ctx.map.get('workshop', r.workshop_id) ?? null,
status: PREORDER_STATUS[int(r.sts) ?? 1] ?? PreOrderStatus.PLACED,
designImageUrl: await ctx.imageUrl(r.file_id, S),
colorName: text(r.color),
sizeSpec: text(r.size),
stoneColor: text(r.color_stone),
minaColor: text(r.color_mina),
estimatedWeightGrams: r.weight === null ? null : grams(r.weight),
estimatedPriceRial: sumText && /^\d+(\.\d+)?$/.test(sumText) ? rial(sumText) : null,
description: text(r.description),
};
const res = await upsert(ctx, 'preorder', r.id, {
exists: async (id) => !!(await ctx.db.preOrder.findUnique({ where: { id } })),
find: async () => null,
create: () => ctx.db.preOrder.create({ data: { ...data, createdAt: tehran(r.created_at) ?? undefined } }),
update: async (id) => {
await ctx.db.preOrder.update({ where: { id }, data });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'updated');
}
}
@@ -0,0 +1,558 @@
/**
* People and places: cities, roles, branches, admin users, sales experts,
* workshops and customers. Rules: docs/DATA-MIGRATION-MAP.md §2–§6, §10.4–§10.7.
*/
import { AdminRole, CustomerKind, Prisma, VerificationStep } from '@prisma/client';
import { ALL_RIDS } from '../../../src/modules/auth/acl.constants';
import { isPasswordHash, OTP_ONLY_PASSWORD_HASH } from '../../../src/modules/auth/password-hash';
import { fillEmpty, upsert, type Ctx } from '../lib/context';
import { birthday, grams, int, isZero, matchKey, mobile, percent, tehran, text } from '../lib/normalize';
type Row = Record<string, unknown>;
/** Owner ruling 2026-10-07 for the 7 legacy rejections, which recorded no reason. */
export const LEGACY_REJECTION_REASON = 'رد شده در سیستم قدیم — فاقد شرح علت ثبت‌شده';
const fullNameOf = (r: Row) =>
text(r.name) ?? text([text(r.fname), text(r.lname)].filter(Boolean).join(' ')) ?? String(r.mobile ?? '');
/** A legacy bcrypt hash keeps working; anything else becomes OTP-only (§10.4). */
const passwordOf = (r: Row) => (isPasswordHash(r.password) ? String(r.password) : OTP_ONLY_PASSWORD_HASH);
/* ───────────── cities ───────────── */
export async function cities(ctx: Ctx) {
const S = 'cities';
// Live cities plus any deleted one a live customer still points at.
const rows = await ctx.q<Row>(
`SELECT id, title, parent, level, percent, percent_aux FROM cities
WHERE deleted_at IS NULL
OR id IN (SELECT city_id FROM customers WHERE deleted_at IS NULL AND city_id IS NOT NULL)`,
);
// Legacy is a tree (country > province > city > district) and so is the
// target (PERS-02): rows first, then each one's parent once all are mapped.
const existing = await ctx.db.city.findMany();
const byKey = new Map(existing.map((c) => [matchKey(c.name), c.id]));
for (const r of rows) {
const name = text(r.title);
if (!name) {
ctx.report.count(S, 'skipped: no title');
continue;
}
const data = {
name,
percent: r.percent === null ? null : percent(r.percent),
percentAux: r.percent_aux === null ? null : percent(r.percent_aux),
};
const res = await upsert(ctx, 'city', r.id, {
exists: async (id) => !!(await ctx.db.city.findUnique({ where: { id } })),
find: async () => {
const id = byKey.get(matchKey(name));
return id ? { id } : null;
},
create: () => ctx.db.city.create({ data }),
update: async (id, how) => {
if (how === 'created') {
await ctx.db.city.update({ where: { id }, data });
} else {
const cur = await ctx.db.city.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, data);
if (Object.keys(patch).length) await ctx.db.city.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated');
byKey.set(matchKey(name), res.id);
}
// Parents. A row the import created takes legacy's parent; a matched row
// keeps the parent it already has (fill-empty, as every other field). A
// parent that was not imported (deleted in legacy) leaves the row on top.
const parentOf = new Map((await ctx.db.city.findMany({ select: { id: true, parentId: true } })).map((c) => [c.id, c.parentId]));
for (const r of rows) {
const id = ctx.map.get('city', r.id);
const parentId = r.parent === null ? null : ctx.map.get('city', r.parent);
if (!id || !parentId || parentId === id) continue;
if (ctx.map.how('city', r.id) !== 'created' && parentOf.get(id)) continue;
if (parentOf.get(id) === parentId) continue;
// Never close a loop: `id` must not be an ancestor of its new parent.
let cur: string | null | undefined = parentId;
let loop = false;
for (let guard = 0; cur && guard < 50; guard++) {
if (cur === id) loop = true;
cur = parentOf.get(cur);
}
if (loop) {
ctx.report.note(S, `legacy city ${r.id}: parent ${r.parent} would make a loop — left as is`);
continue;
}
await ctx.db.city.update({ where: { id }, data: { parentId } });
parentOf.set(id, parentId);
ctx.report.count(S, 'parent set');
}
}
/** Target City id for an exact (normalised) name match, or null. */
async function cityByName(ctx: Ctx, name: string | null): Promise<string | null> {
if (!name) return null;
const all = await ctx.db.city.findMany({ select: { id: true, name: true } });
const key = matchKey(name);
return all.find((c) => matchKey(c.name) === key)?.id ?? null;
}
/* ───────────── roles & permissions ───────────── */
/**
* Legacy 85 «مدیر» and target 85 «مدیر ارشد» are the same full-access role:
* legacy 85 holds 173 permissions, more than any other type, and target 85 holds
* every one of the 144 rids. Every other type is matched by TITLE, never by id —
* legacy 86 is «حسابدار» but target 86 is «کارشناس فروش» (seed), so an id copy
* would hand the accountant's 71 permissions to every sales expert.
*/
const SAME_ROLE: ReadonlyMap<number, number> = new Map([[85, 85]]);
/**
* Legacy rids the target renamed (PERM-01): the wholesale and retail dashboards
* («آمار عمده فروشی» dashboard.omde, «آمار تک فروشی» dashboard.tak) and their
* tree parent became the one dashboard behind "dashboard.view". Without this a
* role that saw a dashboard in legacy would lose it.
*/
const RENAMED_RIDS: ReadonlyMap<string, string> = new Map([
['dashboard', 'dashboard.view'],
['dashboard.omde', 'dashboard.view'],
['dashboard.tak', 'dashboard.view'],
]);
/** AdminRole is a label only — every permission comes from the user type. */
function adminRoleFor(legacyUt: number | null): AdminRole {
if (legacyUt === 85 || legacyUt === 104 || legacyUt === 105) return AdminRole.SUPER_ADMIN; // مدیر · مدیر اصلی · مدیران
if (legacyUt === 86) return AdminRole.ACCOUNTANT; // حسابدار
return AdminRole.SYSTEM_APPROVER;
}
export async function roles(ctx: Ctx) {
const S = 'roles & permissions';
// Legacy user types are config_statics rows (UserController.php:26:
// `exists:config_statics,id`), titled by `val`.
const types = await ctx.q<Row>(
"SELECT id, val FROM config_statics WHERE `key` = '__list__ut' AND deleted_at IS NULL ORDER BY id",
);
const perms = await ctx.q<{ rid: string; ut: number }>('SELECT DISTINCT rid, ut FROM permission');
const known = new Set<string>(ALL_RIDS as readonly string[]);
const target = await ctx.db.userType.findMany();
const byTitle = new Map(target.map((t) => [matchKey(t.title), t.id]));
const takenIds = new Set(target.map((t) => t.id));
for (const t of types) {
const legacyId = Number(t.id);
const title = text(t.val) ?? `نقش ${legacyId}`;
const mapped = ctx.map.get('ut', legacyId);
let targetId: number;
let created = false;
if (mapped) {
targetId = Number(mapped);
} else if (SAME_ROLE.has(legacyId) && takenIds.has(SAME_ROLE.get(legacyId)!)) {
targetId = SAME_ROLE.get(legacyId)!;
await ctx.map.set('ut', legacyId, String(targetId), 'matched');
} else if (byTitle.has(matchKey(title))) {
targetId = byTitle.get(matchKey(title))!;
await ctx.map.set('ut', legacyId, String(targetId), 'matched');
if (targetId !== legacyId) ctx.report.note(S, `legacy type ${legacyId} «${title}» → existing target type ${targetId} (same title, different number)`);
} else {
targetId = legacyId;
if (takenIds.has(targetId)) {
targetId = Math.max(...takenIds, ...types.map((x) => Number(x.id))) + 1;
ctx.report.note(S, `legacy type ${legacyId} «${title}»: number taken by a different target role, created as ${targetId}`);
}
await ctx.db.userType.create({ data: { id: targetId, title } });
takenIds.add(targetId);
await ctx.map.set('ut', legacyId, String(targetId), 'created');
created = true;
}
const legacyRids = [
...new Set(perms.filter((p) => Number(p.ut) === legacyId).map((p) => RENAMED_RIDS.get(String(p.rid)) ?? String(p.rid))),
];
if (created || ctx.map.how('ut', legacyId) === 'created') {
// A role the import created gets legacy's permissions, limited to rids the
// target ACL knows (the storefront's are out of scope, D13).
const usable = legacyRids.filter((r) => known.has(r));
const res = await ctx.db.permission.createMany({
data: usable.map((rid) => ({ rid, userTypeId: targetId })),
skipDuplicates: true,
});
ctx.report.count(S, 'permissions copied', res.count);
ctx.report.count(S, 'permissions dropped (rid unknown to the target ACL)', legacyRids.length - usable.length);
ctx.report.count(S, 'types created');
} else {
// An existing target role keeps the target's permission set (D1: the
// target's stricter auth stands). Nothing is added to it.
ctx.report.count(S, 'types matched (permissions left as the target has them)');
ctx.report.count(S, 'legacy permissions not applied to matched types', legacyRids.length);
}
}
const orphanUt = [...new Set(perms.map((p) => Number(p.ut)))].filter((u) => !types.some((t) => Number(t.id) === u));
if (orphanUt.length) ctx.report.note(S, `permission rows for types with no config_statics row were skipped: ${orphanUt.join(', ')}`);
}
/* ───────────── branches & workshops ───────────── */
export async function branches(ctx: Ctx) {
const S = 'branches';
const rows = await ctx.q<Row>('SELECT * FROM branches WHERE deleted_at IS NULL');
const existing = await ctx.db.branch.findMany();
for (const r of rows) {
const name = text(r.name);
if (!name) continue;
const data = { name, address: text(r.address), phone: text(r.phone), isActive: int(r.is_active) !== 0 };
const res = await upsert(ctx, 'branch', r.id, {
exists: async (id) => !!(await ctx.db.branch.findUnique({ where: { id } })),
find: async () => existing.find((b) => matchKey(b.name) === matchKey(name)) ?? null,
create: () => ctx.db.branch.create({ data }),
update: async (id, how) => {
if (how === 'created') await ctx.db.branch.update({ where: { id }, data });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'matched/updated');
}
// branches.user_id names the branch's manager; the target Branch has no such field.
ctx.report.note(S, 'branches.user_id (branch manager) has no target field and was not carried over.');
}
export async function workshops(ctx: Ctx) {
const S = 'workshops';
const rows = await ctx.q<Row>('SELECT * FROM workshops WHERE deleted_at IS NULL');
const existing = await ctx.db.workshop.findMany();
for (const r of rows) {
const name = text(r.name);
if (!name) continue;
const data = { name, address: text(r.address), phone: text(r.phone), isActive: int(r.is_active) !== 0 };
const res = await upsert(ctx, 'workshop', r.id, {
exists: async (id) => !!(await ctx.db.workshop.findUnique({ where: { id } })),
find: async () => existing.find((w) => matchKey(w.name) === matchKey(name)) ?? null,
create: () => ctx.db.workshop.create({ data }),
update: async (id, how) => {
if (how === 'created') await ctx.db.workshop.update({ where: { id }, data });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'matched/updated');
}
// Legacy Workshop::user() is belongsTo(Customer) (a type-5 customer); the
// target supervisor is an AdminUser. No safe link exists, so none is guessed.
ctx.report.note(S, 'supervisor not linked: legacy points at a customer (Workshop::user → Customer), the target expects an admin user.');
}
/* ───────────── admin users (legacy `users`) ───────────── */
export async function admins(ctx: Ctx) {
const S = 'admin users';
const rows = await ctx.q<Row>('SELECT * FROM users WHERE deleted_at IS NULL');
for (const r of rows) {
const m = mobile(r.mobile);
if (!m) {
ctx.report.count(S, 'skipped: no mobile');
continue;
}
const ut = int(r.ut);
const userTypeId = ctx.map.get('ut', ut);
const branchId = ctx.map.get('branch', r.branch_id) ?? null;
const base = {
fullName: fullNameOf(r),
firstName: text(r.fname),
lastName: text(r.lname),
branchId,
userTypeId: userTypeId ? Number(userTypeId) : null,
};
const res = await upsert(ctx, 'admin', r.id, {
exists: async (id) => !!(await ctx.db.adminUser.findUnique({ where: { id } })),
find: () => ctx.db.adminUser.findUnique({ where: { mobile: m }, select: { id: true } }),
create: () =>
ctx.db.adminUser.create({
data: {
...base,
mobile: m,
passwordHash: passwordOf(r),
role: adminRoleFor(ut),
createdAt: tehran(r.created_at) ?? undefined,
},
}),
update: async (id, how) => {
if (how === 'created') {
await ctx.db.adminUser.update({ where: { id }, data: base });
} else {
// An admin the target already has keeps its role, type, password and
// branch; only empty name parts are filled.
const cur = await ctx.db.adminUser.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, { firstName: base.firstName, lastName: base.lastName });
if (Object.keys(patch).length) await ctx.db.adminUser.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (left as is)' : 'updated');
if (res.isNew && !isPasswordHash(r.password)) ctx.report.count(S, 'created without a usable password');
}
}
/* ───────────── sales experts (legacy customers.type = 1) ───────────── */
/**
* D17: an expert is an AdminUser (role SALES_EXPERT) with a SalesExpertProfile.
* Their own orders and debt live on a separate trading Customer, created by the
* customers step for the experts who have any (13 with orders, 14 with
* documents in the 2026-10-04 dump).
*
* An expert whose mobile already belongs to an admin with another role (7 in
* the dump, three of them full admins) is NOT demoted: the admin keeps its
* role, and the expert's referrals point at that admin.
*/
export async function experts(ctx: Ctx) {
const S = 'sales experts';
const rows = await ctx.q<Row>('SELECT * FROM customers WHERE deleted_at IS NULL AND type = 1');
const existingExpertType = await ctx.db.adminUser.groupBy({
by: ['userTypeId'],
where: { role: AdminRole.SALES_EXPERT, userTypeId: { not: null } },
_count: true,
orderBy: { _count: { userTypeId: 'desc' } },
});
const expertType =
existingExpertType[0]?.userTypeId ??
(await ctx.db.userType.findMany()).find((t) => matchKey(t.title) === matchKey('کارشناس فروش'))?.id ??
null;
if (expertType === null) ctx.report.note(S, 'no target user type for sales experts found; created experts have no user type');
for (const r of rows) {
const m = mobile(r.mobile);
if (!m) {
ctx.report.count(S, 'skipped: no mobile');
continue;
}
const profile = {
cityId: ctx.map.get('city', r.city_id) ?? null,
gramLevel: grams(r.gram_level),
phone: text(r.phone),
nationalCode: text(r.ncode),
};
const owned = {
fullName: fullNameOf(r),
firstName: text(r.fname),
lastName: text(r.lname),
// AdminUser.wagePercent is "legacy Customer.percent" (schema).
wagePercent: r.percent === null ? null : percent(r.percent),
isActive: int(r.active) !== -1,
avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S),
address: text(r.address),
storeName: text(r.store_name),
pasazhName: text(r.pasazh_name),
memberCode: text(r.mcode),
};
const res = await upsert(ctx, 'expert', r.id, {
exists: async (id) => !!(await ctx.db.adminUser.findUnique({ where: { id } })),
find: () => ctx.db.adminUser.findUnique({ where: { mobile: m }, select: { id: true } }),
create: () =>
ctx.db.adminUser.create({
data: {
...owned,
mobile: m,
passwordHash: passwordOf(r),
role: AdminRole.SALES_EXPERT,
userTypeId: expertType,
createdAt: tehran(r.created_at) ?? undefined,
},
}),
update: async (id, how) => {
// A matched admin (the target's own, or a legacy `users` admin) is not
// rewritten here; the role check below decides what else happens.
if (how === 'created') await ctx.db.adminUser.update({ where: { id }, data: owned });
},
});
const admin = await ctx.db.adminUser.findUniqueOrThrow({
where: { id: res.id },
include: { salesExpertProfile: true },
});
if (admin.role !== AdminRole.SALES_EXPERT) {
// Owner 2026-10-07: the admin keeps their role. Their expert profile is
// still kept, because it is what links their OWN legacy orders (their
// trading account) to them — without it their «میزان فروش» lost those
// orders. The profile grants nothing: the expert app checks the role.
ctx.report.count(S, `mobile already an admin with role ${admin.role} — kept, not demoted`);
ctx.report.note(S, `legacy expert #${String(r.id)} shares a mobile with admin role ${admin.role}; the admin was kept and referrals point to it.`);
}
if (!admin.salesExpertProfile) {
await ctx.db.salesExpertProfile.create({ data: { adminUserId: admin.id, ...profile } });
} else if (res.how === 'created') {
await ctx.db.salesExpertProfile.update({ where: { id: admin.salesExpertProfile.id }, data: profile });
} else {
const patch = fillEmpty(admin.salesExpertProfile, profile);
if (Object.keys(patch).length) await ctx.db.salesExpertProfile.update({ where: { id: admin.salesExpertProfile.id }, data: patch });
}
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing expert' : 'updated');
}
}
/* ───────────── customers ───────────── */
/** Legacy Customer::$TYPES (Customer.php:85-92); NULL predates sign-up's `type = 2`. */
function kindFor(type: number | null): CustomerKind {
switch (type) {
case 3: return CustomerKind.WALK_IN_SHOPKEEPER;
case 4: return CustomerKind.BANKER;
case 5: return CustomerKind.WORKSHOP_SUPERVISOR;
default: return CustomerKind.APP_SHOPKEEPER; // 2, 1 (expert's trading account), NULL
}
}
/**
* Two 3-state legacy columns (1 pending, 2 accepted, 3 rejected; §5.5) to one
* enum. Step one governs: a rejected step one closed the case in legacy
* (`active = -1`), and a pending step one means review never got past it.
*/
function verificationFor(one: number | null, two: number | null): VerificationStep {
if (one === 3 || (one !== 1 && two === 3)) return VerificationStep.REJECTED;
if (one === 1) return VerificationStep.STEP_ONE_SUBMITTED;
if (one === 2) return two === 2 ? VerificationStep.VERIFIED : VerificationStep.STEP_TWO_SUBMITTED;
return VerificationStep.UNVERIFIED;
}
export async function customers(ctx: Ctx) {
const S = 'customers';
const rows = await ctx.q<Row>('SELECT * FROM customers WHERE deleted_at IS NULL');
const withOrders = new Set((await ctx.q<Row>('SELECT DISTINCT customer_id AS id FROM orders WHERE deleted_at IS NULL')).map((r) => String(r.id)));
const withCash = new Set((await ctx.q<Row>('SELECT DISTINCT customer_id AS id FROM cashes WHERE deleted_at IS NULL')).map((r) => String(r.id)));
const hasMoney = (r: Row) =>
withOrders.has(String(r.id)) || withCash.has(String(r.id)) || !isZero(r.remain) || !isZero(r.rial_remain);
// An expert becomes a Customer only as a trading account, i.e. when they
// have money of their own; otherwise they exist only as an AdminUser.
const candidates = rows.filter((r) => int(r.type) !== 1 || hasMoney(r));
ctx.report.count(S, 'experts without money (admin user only)', rows.length - candidates.length);
// Duplicate mobiles (27 groups, §10.4). The winner is the row with money
// (never more than one in the dump), else the most recently updated.
const groups = new Map<string, Row[]>();
for (const r of candidates) {
const m = mobile(r.mobile);
if (!m) {
// In the 2026-10-04 dump this is one test account whose "mobile" is the
// word «تست»: it cannot log in under any system, so it is not invented a number.
ctx.report.count(S, 'skipped: mobile has no digits');
const own = withOrders.has(String(r.id)) || withCash.has(String(r.id));
ctx.report.note(S, `legacy customer #${String(r.id)} skipped: its mobile contains no digits${own ? ' — its orders/documents are skipped with it' : ''}`);
continue;
}
groups.set(m, [...(groups.get(m) ?? []), r]);
}
for (const [m, group] of groups) {
const ordered = [...group].sort(
(a, b) =>
Number(hasMoney(b)) - Number(hasMoney(a)) ||
String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')) ||
Number(b.id) - Number(a.id),
);
const [r, ...losers] = ordered;
const moneyLosers = losers.filter(hasMoney);
if (moneyLosers.length) {
// Not seen in the 2026-10-04 dump; if a fresh dump has it, the money of
// two legacy rows lands on one customer and must be reviewed by hand.
ctx.report.note(S, `⚠ duplicate mobile with money on several rows: legacy #${[r, ...moneyLosers].map((x) => String(x.id)).join(', #')} merged onto one customer — review`);
}
const type = int(r.type);
const step = verificationFor(int(r.sts_verify_step_one), int(r.sts_verify_step_two));
const typedCity = text(r.new_city);
// Sign-up forced city_id = 5 (تهران) on everyone (Major/CustomerController.php:21),
// so when the customer typed a city, that text — not city_id 5 — is the truth.
const cityId = typedCity
? int(r.city_id) !== 5 && r.city_id !== null
? ctx.map.get('city', r.city_id) ?? null
: await cityByName(ctx, typedCity)
: ctx.map.get('city', r.city_id) ?? null;
const owned = {
firstName: text(r.fname),
lastName: text(r.lname),
nationalCode: text(r.ncode), // empty -> NULL; '' would collide under @unique (§10.4)
cityId,
cityNameRaw: typedCity,
kind: kindFor(type),
phone: text(r.phone),
address: text(r.address),
storeName: text(r.store_name),
pasazhName: text(r.pasazh_name),
zone: text(r.zone),
memberCode: text(r.mcode),
referredByAgentId: ctx.map.get('expert', r.reagent_id) ?? null,
goldCreditLimitGrams: grams(r.gram_level),
makingChargeOffsetPercent: percent(r.percent),
verificationStep: step,
verificationRejectionReason: step === VerificationStep.REJECTED ? LEGACY_REJECTION_REASON : null,
nationalIdImageUrl: await ctx.imageUrl(r.ncode_image_id, S),
businessLicenseUrl: await ctx.imageUrl(r.business_license_image_id, S),
shopImageUrl: await ctx.imageUrl(r.shop_image_id, S),
avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S),
birthDate: birthday(r.birth_date, r.created_at),
// Owner ruling 2026-10-08 (MON-05, option 2): everyone may order after the
// move. Legacy customers.sts was never read by any code — most of the 313
// «عدم سفارش» rows are just the sign-up default (ApiMobileController.php:704)
// — so carrying it over would suddenly block real customers once the new
// system enforces the switch. Only a deliberate suspension after go-live counts.
canPlaceOrders: true,
isActive: int(r.active) !== -1,
isArchived: int(r.is_archive) === 1,
};
if (r.reagent_id !== null && !owned.referredByAgentId) ctx.report.count(S, 'referrer not mapped (not a migrated expert)');
if (int(r.sts) === -1) ctx.report.count(S, 'legacy «عدم سفارش» (never enforced) — set to allowed (owner, option 2)');
if (r.birth_date !== null && !owned.birthDate) ctx.report.count(S, 'birth date dropped (sign-up default or impossible)');
const res = await upsert(ctx, 'customer', r.id, {
exists: async (id) => !!(await ctx.db.customer.findUnique({ where: { id } })),
find: () => ctx.db.customer.findUnique({ where: { mobile: m }, select: { id: true } }),
create: () =>
ctx.db.customer.create({
data: {
...owned,
fullName: fullNameOf(r),
mobile: m,
passwordHash: passwordOf(r),
createdAt: tehran(r.created_at) ?? undefined,
} satisfies Prisma.CustomerUncheckedCreateInput,
}),
update: async (id, how) => {
if (how === 'created') {
// Never the accounting* columns (D27, §10.5): not in `owned`.
await ctx.db.customer.update({ where: { id }, data: owned });
} else {
const cur = await ctx.db.customer.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, owned);
// The target's own verification state wins; a legacy rejection reason
// on a customer the target shows as anything but REJECTED would contradict it.
if (cur.verificationStep !== VerificationStep.REJECTED) delete patch.verificationRejectionReason;
if (Object.keys(patch).length) await ctx.db.customer.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (empty fields filled)' : 'updated');
if (step === VerificationStep.REJECTED) ctx.report.count(S, 'rejected (legacy reason sentinel written)');
for (const l of losers) {
await ctx.map.set('customer', l.id, res.id, 'merged');
ctx.report.count(S, 'duplicate-mobile rows merged into the winner');
}
// An expert's trading account (D17): link it from their profile.
if (type === 1) {
const adminId = ctx.map.get('expert', r.id);
const profile = adminId ? await ctx.db.salesExpertProfile.findUnique({ where: { adminUserId: adminId } }) : null;
if (profile && !profile.tradingCustomerId) {
await ctx.db.salesExpertProfile.update({ where: { id: profile.id }, data: { tradingCustomerId: res.id } });
ctx.report.count(S, 'expert trading accounts linked');
}
}
}
}
@@ -0,0 +1,147 @@
/**
* Reconciliation (GO-LIVE-PLAN §7): what legacy has vs what the target now
* holds, with the tolerance of DATA-MIGRATION-MAP §4 instead of "zero
* difference", which double → Decimal rounding makes impossible.
*/
import { Prisma } from '@prisma/client';
import type { Ctx } from '../lib/context';
import { toNumber } from '../lib/normalize';
import { agentSalesWhere, customerSalesWhere } from '../../../src/modules/sales-agent/agent-orders';
type Row = Record<string, unknown>;
const TOLERANCE = new Prisma.Decimal('0.0001');
export async function reconcile(ctx: Ctx) {
const S = 'reconciliation';
/* counts: legacy rows in scope vs ids mapped */
const scope: [string, string, string][] = [
['cities', 'city', `SELECT COUNT(*) n FROM cities WHERE deleted_at IS NULL`],
['branches', 'branch', `SELECT COUNT(*) n FROM branches WHERE deleted_at IS NULL`],
['workshops', 'workshop', `SELECT COUNT(*) n FROM workshops WHERE deleted_at IS NULL`],
['admin users', 'admin', `SELECT COUNT(*) n FROM users WHERE deleted_at IS NULL`],
['sales experts', 'expert', `SELECT COUNT(*) n FROM customers WHERE deleted_at IS NULL AND type = 1`],
['live products', 'product', `SELECT COUNT(*) n FROM product WHERE deleted_at IS NULL`],
['live variants', 'variant', `SELECT COUNT(*) n FROM products_productvarient WHERE deleted_at IS NULL`],
['live orders', 'order', `SELECT COUNT(*) n FROM orders WHERE deleted_at IS NULL`],
['custom orders', 'preorder', `SELECT COUNT(*) n FROM custom_orders WHERE deleted_at IS NULL`],
];
const rows: string[] = ['| | legacy | mapped |', '|---|---:|---:|'];
for (const [label, entity, sql] of scope) {
const [{ n }] = await ctx.q<{ n: number }>(sql);
const [{ m }] = await ctx.db.$queryRawUnsafe<{ m: bigint }[]>(
`SELECT COUNT(*) AS m FROM legacy_import.id_map WHERE entity = $1`, entity,
);
rows.push(`| ${label} | ${String(n)} | ${String(m)} |`);
}
ctx.report.note(S, `Counts (mapped may exceed legacy live rows where deleted rows were kept for order history):\n\n${rows.join('\n')}\n`);
/* balances: every migrated customer, legacy stored vs target cached */
const legacy = await ctx.q<Row>('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL');
const want = new Map<string, { gold: number; rial: number }>();
for (const r of legacy) {
const id = ctx.map.get('customer', r.id);
if (!id) continue;
const w = want.get(id) ?? { gold: 0, rial: 0 };
w.gold += toNumber(r.remain);
w.rial += toNumber(r.rial_remain);
want.set(id, w);
}
const targets = await ctx.db.customer.findMany({
where: { id: { in: [...want.keys()] } },
select: { id: true, goldDebtGrams: true, fiatDebtRial: true },
});
// Matched customers may carry target-native entries too, so only the
// customers the import created are held to legacy's number.
const created = new Set(
(await ctx.db.$queryRawUnsafe<{ target_id: string }[]>(
`SELECT DISTINCT target_id FROM legacy_import.id_map WHERE entity = 'customer' AND how = 'created'`,
)).map((r) => r.target_id),
);
let goldOk = 0, goldBad = 0, rialOk = 0, rialBad = 0;
let legacyGold = new Prisma.Decimal(0), targetGold = new Prisma.Decimal(0);
let legacyRial = new Prisma.Decimal(0), targetRial = new Prisma.Decimal(0);
for (const t of targets) {
if (!created.has(t.id)) continue;
const w = want.get(t.id)!;
const g = new Prisma.Decimal(w.gold.toFixed(4));
const r = new Prisma.Decimal(w.rial.toFixed(2));
legacyGold = legacyGold.add(g); targetGold = targetGold.add(t.goldDebtGrams);
legacyRial = legacyRial.add(r); targetRial = targetRial.add(t.fiatDebtRial);
if (g.sub(t.goldDebtGrams).abs().lte(TOLERANCE)) goldOk++;
else { goldBad++; ctx.report.note(S, `⚠ gold balance differs for customer ${t.id}: legacy ${g.toString()} / target ${t.goldDebtGrams.toString()}`); }
if (r.sub(t.fiatDebtRial).abs().lt(1)) rialOk++;
else { rialBad++; ctx.report.note(S, `⚠ rial balance differs for customer ${t.id}: legacy ${r.toString()} / target ${t.fiatDebtRial.toString()}`); }
}
ctx.report.count(S, 'created customers — gold balance matches legacy (±0.0001 g)', goldOk);
ctx.report.count(S, 'created customers — gold balance DIFFERS', goldBad);
ctx.report.count(S, 'created customers — rial balance matches legacy (±1 rial)', rialOk);
ctx.report.count(S, 'created customers — rial balance DIFFERS', rialBad);
ctx.report.note(S, `Total gold debt (created customers): legacy ${legacyGold.toFixed(4)} g · target ${targetGold.toFixed(4)} g`);
ctx.report.note(S, `Total rial debt (created customers): legacy ${legacyRial.toFixed(2)} · target ${targetRial.toFixed(2)}`);
ctx.report.note(S, `Matched (pre-existing) customers are not held to legacy's number: they may carry target-native ledger entries. ${targets.length - created.size} of them; see the "balances" section for whose cached balance changed.`);
/* protected columns: the import must never have written them */
const [{ n: imageIds }] = await ctx.db.$queryRawUnsafe<{ n: bigint }[]>(
`SELECT COUNT(*) AS n FROM "ProductVariant" v
JOIN legacy_import.id_map m ON m.entity = 'variant' AND m.target_id = v.id AND m.how = 'created'
WHERE v."imageId" IS NOT NULL`,
);
ctx.report.note(S, `Imported variants with an imageId: ${String(imageIds)} (must be 0 — PROTECTED.md §B).`);
}
/**
* «میزان فروش» for every migrated customer and expert, against legacy's own
* formula (CustomerController::updateGramSell, soft-deleted orders excluded):
* SUM(orders.input) WHERE customer_id = X OR reagent_id = X
* computed live from legacy's rows — and, separately, legacy's STORED
* `customers.grams_sell`, which the legacy screens print. The stored value is
* a cache refreshed only on some order events, so where it disagrees with the
* formula the legacy screen itself is out of date; each such row is listed.
*
* Legacy rows the import merged into one target row (duplicate mobiles) are
* compared as one: their legacy figures are summed.
*/
export async function reconcileSales(ctx: Ctx) {
const S = 'reconciliation — sales («میزان فروش»)';
const rows = await ctx.q<Row>(
`SELECT c.id, c.grams_sell AS kept,
(SELECT COALESCE(SUM(o.input), 0) FROM orders o
WHERE o.deleted_at IS NULL AND (o.customer_id = c.id OR o.reagent_id = c.id)) AS live
FROM customers c WHERE c.deleted_at IS NULL`,
);
const groups = new Map<string, { where: Prisma.OrderWhereInput; live: number; ids: string[] }>();
let stale = 0;
for (const r of rows) {
const agentId = ctx.map.get('expert', r.id);
const customerId = ctx.map.get('customer', r.id);
if (!agentId && !customerId) continue;
const key = agentId ? `expert:${agentId}` : `customer:${customerId!}`;
const g = groups.get(key) ?? {
where: agentId ? agentSalesWhere(agentId) : customerSalesWhere(customerId!),
live: 0,
ids: [],
};
g.live += toNumber(r.live);
g.ids.push(String(r.id));
groups.set(key, g);
const live = new Prisma.Decimal(toNumber(r.live).toFixed(4));
const kept = new Prisma.Decimal(toNumber(r.kept).toFixed(4));
if (kept.sub(live).abs().gt(TOLERANCE)) {
stale++;
ctx.report.note(S, `legacy's stored figure is out of date for #${String(r.id)}: legacy screen shows ${kept.toString()}, legacy's own formula gives ${live.toString()}`);
}
}
let ok = 0, bad = 0;
for (const [key, g] of groups) {
const agg = await ctx.db.order.aggregate({ where: g.where, _sum: { totalGoldWeightGrams: true } });
const target = agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0);
const live = new Prisma.Decimal(g.live.toFixed(4));
if (live.sub(target).abs().lte(TOLERANCE)) ok++;
else { bad++; ctx.report.note(S, `⚠ ${key.split(':')[0]} legacy #${g.ids.join(' + #')}: legacy formula ${live.toString()} / target ${target.toString()}`); }
}
ctx.report.count(S, 'matches legacy formula (±0.0001 g)', ok);
ctx.report.count(S, 'DIFFERS from legacy formula', bad);
ctx.report.count(S, "legacy's stored screen value is stale (legacy disagrees with itself)", stale);
}
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "../..",
"noEmit": true
},
"include": ["./**/*.ts"]
}
+86
View File
@@ -0,0 +1,86 @@
/**
* D0 requirement 3 — review report for names that cannot be split safely.
*
* The migration backfills firstName = first token, lastName = the rest. That is
* correct for two-token names and a guess for anything longer: "سید محمد
* حسینی" should almost certainly be firstName="سید محمد", and the migration
* writes firstName="سید" instead. Those rows need a human.
*
* READ-ONLY. It changes nothing; it prints what a person has to check.
*
* node scripts/report-name-split.cjs # print to stdout
* node scripts/report-name-split.cjs --csv out.csv
*
* Run it AFTER the 20260922120000_customer_name_parts migration.
*/
const { PrismaClient } = require('@prisma/client');
const fs = require('fs');
const prisma = new PrismaClient();
/** Same normalisation the migration's SQL uses. */
const tokens = (s) => String(s ?? '').trim().replace(/\s+/g, ' ').split(' ').filter(Boolean);
(async () => {
const customers = await prisma.$queryRawUnsafe(`
SELECT id, "fullName", "firstName", "lastName", mobile, "memberCode"
FROM "Customer"
ORDER BY "createdAt"
`);
const multi = [];
const empty = [];
const single = [];
for (const c of customers) {
const t = tokens(c.fullName);
if (t.length === 0) empty.push(c);
else if (t.length === 1) single.push(c);
else if (t.length > 2) multi.push({ ...c, tokenCount: t.length });
}
console.log(`Customers scanned: ${customers.length}`);
console.log(` two tokens (split is reliable): ${customers.length - multi.length - empty.length - single.length}`);
console.log(` one token (lastName is NULL): ${single.length}`);
console.log(` empty name (nothing to split): ${empty.length}`);
console.log(` MORE THAN TWO TOKENS — NEEDS REVIEW: ${multi.length}`);
if (single.length) {
console.log('\n--- single-token names (no surname recorded) ---');
for (const c of single) console.log(` ${c.id} ${c.mobile} "${c.fullName}"`);
}
if (empty.length) {
console.log('\n--- empty names ---');
for (const c of empty) console.log(` ${c.id} ${c.mobile}`);
}
if (multi.length) {
console.log('\n--- NEEDS REVIEW: more than two tokens ---');
console.log('The migration guessed first-token / rest. Verify each one.\n');
for (const c of multi) {
console.log(` ${c.id} ${c.mobile} (${c.tokenCount} tokens)`);
console.log(` fullName : "${c.fullName}"`);
console.log(` guessed : firstName="${c.firstName}" lastName="${c.lastName}"`);
}
}
const csvArg = process.argv.indexOf('--csv');
if (csvArg !== -1 && process.argv[csvArg + 1]) {
const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const rows = [
['id', 'mobile', 'memberCode', 'fullName', 'guessedFirstName', 'guessedLastName', 'tokenCount'].join(','),
...multi.map((c) =>
[c.id, c.mobile, c.memberCode, c.fullName, c.firstName, c.lastName, c.tokenCount].map(esc).join(','),
),
];
// BOM so Excel opens the Persian text in the right encoding.
fs.writeFileSync(process.argv[csvArg + 1], '' + rows.join('\n'), 'utf8');
console.log(`\nCSV written: ${process.argv[csvArg + 1]} (${multi.length} rows)`);
}
await prisma.$disconnect();
})().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+168
View File
@@ -0,0 +1,168 @@
/**
* B2B admin scan (D19 step 2) — hits every read endpoint the wholesale side of
* the admin panel uses on a normal working day, looking for things that are
* OUTRIGHT BROKEN (500s, crashes, empty required shapes).
*
* Scope per the owner: wholesale customers, sales experts, products and
* variants, wholesale orders, documents. Retail/storefront surfaces
* (single*, wallet, comments) are D13 — preserved, not scanned.
*
* This is a breakage scan, not a parity audit: a 200 with plausible data
* passes. Anything non-2xx is reported with its body so it can be triaged.
*
* Usage: node scripts/scan-admin-b2b.cjs [apiBaseUrl]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
const broken = [];
const ok = [];
let H = {};
async function probe(label, method, path, body) {
let res;
try {
res = await fetch(`${API}/${path}`, {
method,
headers: H,
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (e) {
broken.push({ label, method, path, status: 'NETWORK', detail: String(e) });
console.log(` ERR ${label} (${String(e)})`);
return null;
}
let payload = null;
const text = await res.text();
try {
payload = JSON.parse(text);
} catch {
/* non-JSON */
}
if (res.ok) {
ok.push(label);
const n = Array.isArray(payload)
? payload.length
: Array.isArray(payload?.rows)
? payload.rows.length
: null;
console.log(` ok ${label}${n === null ? '' : ` (${n} rows)`}`);
} else {
const detail =
(payload && (payload.message || payload.error)) || text.slice(0, 200);
broken.push({ label, method, path, status: res.status, detail });
console.log(` FAIL ${label} -> ${res.status} ${JSON.stringify(detail).slice(0, 160)}`);
}
return payload;
}
const page = { page: 1, limit: 5 };
(async () => {
const login = await fetch(`${API}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
});
const auth = await login.json();
if (!auth.api_token) {
console.log('login failed');
process.exit(1);
}
H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token };
console.log('\n── WHOLESALE CUSTOMERS ──────────────────────────────');
const omde = await probe('customers: omde list', 'POST', 'admin/customers-omde/list', page);
await probe('customers: banker list', 'POST', 'admin/customers-omde/banker/list', page);
await probe('customers: shopkeeper list', 'POST', 'admin/customers-omde/shopkeeper/list', page);
await probe('customers: non-verified list', 'POST', 'admin/customers-omde/non-verified/list', page);
await probe('customers: archived list', 'POST', 'admin/customers-omde/archived/list', page);
await probe('customers: referrer dropdown', 'GET', 'admin/customers-omde/query/reagent');
const aCustomer = omde?.rows?.[0];
if (aCustomer) {
await probe('customer: detail', 'GET', `admin/customers-omde/${aCustomer.id}`);
await probe('customer: dashboard', 'GET', `admin/customers-omde/dashboard/${aCustomer.id}`);
await probe('customer: orders tab', 'GET', `admin/customers-omde/${aCustomer.id}/orders`);
await probe('customer: documents tab', 'GET', `admin/customers-omde/${aCustomer.id}/documents`);
}
console.log('\n── SALES EXPERTS ────────────────────────────────────');
const reagents = await probe('experts: list', 'POST', 'admin/customers-omde/reagent/list', page);
const anExpert = reagents?.rows?.[0];
if (anExpert) {
await probe('expert: detail', 'GET', `admin/customers-omde/reagent/${anExpert.id}`);
await probe('expert: as admin user', 'GET', `admin/users/${anExpert.id}`);
}
console.log('\n── PRODUCTS & VARIANTS ──────────────────────────────');
const prods = await probe('products: list', 'POST', 'admin/products/list', page);
await probe('products: variants list', 'POST', 'admin/products/variants/list', page);
await probe('products: brands', 'GET', 'admin/products/brands');
await probe('products: brands table', 'POST', 'admin/products/brands-table', page);
await probe('products: collections', 'GET', 'admin/products/collections');
await probe('products: show-type options', 'GET', 'admin/products/show-type-options');
const aProduct = prods?.rows?.[0];
if (aProduct) {
await probe('product: detail (edit form)', 'GET', `admin/products/${aProduct.id}`);
await probe('product: its variants', 'GET', `admin/products/${aProduct.id}/variants`);
// The order builder posts `products_id` (stores/cart.ts:102), not `ids`.
const byIds = await probe('product: by-ids', 'POST', 'admin/products/by-ids', {
products_id: [aProduct.id],
});
if (byIds && Array.isArray(byIds) && byIds.length === 0) {
broken.push({
label: 'product: by-ids returned nothing for a known id',
method: 'POST',
path: 'admin/products/by-ids',
status: '200-empty',
detail: `asked for ${aProduct.id}, got []`,
});
console.log(' FAIL product: by-ids returned [] for a product that exists');
}
}
console.log('\n── WHOLESALE ORDERS ─────────────────────────────────');
const wOrders = await probe('orders: wholesale list', 'POST', 'admin/orders/wholesale/list', page);
await probe('orders: tamin list', 'POST', 'admin/orders/tamin/list', page);
await probe('orders: status options', 'GET', 'admin/orders/status-options');
await probe('orders: type options', 'GET', 'admin/orders/type-options');
const anOrder = wOrders?.rows?.[0];
if (anOrder) {
await probe('order: detail', 'GET', `admin/orders/${anOrder.id}`);
}
console.log('\n── DOCUMENTS ────────────────────────────────────────');
const docs = await probe('documents: list', 'POST', 'admin/documents/list', page);
const aDoc = docs?.rows?.[0];
if (aDoc) await probe('document: detail', 'GET', `admin/documents/${aDoc.id}`);
console.log('\n── SUPPORTING LOOKUPS THE B2B FORMS NEED ────────────');
await probe('cities: list', 'POST', 'admin/cities/list', { page: 1, limit: 500 });
await probe('branches: list', 'POST', 'admin/branches/list', page);
await probe('workshops: list', 'POST', 'admin/workshops/list', page);
await probe('pre-orders: list', 'POST', 'admin/pre-orders/list', page);
await probe('users: list', 'POST', 'admin/users/list', page);
console.log(`\n${'='.repeat(62)}`);
console.log(`B2B SCAN: ${ok.length} ok, ${broken.length} broken`);
if (broken.length) {
console.log('\nBROKEN:');
for (const b of broken) {
console.log(` - [${b.status}] ${b.label}`);
console.log(` ${b.method} ${b.path}`);
console.log(` ${JSON.stringify(b.detail).slice(0, 300)}`);
}
}
console.log('='.repeat(62));
await prisma.$disconnect();
process.exit(broken.length ? 1 : 0);
})().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+244
View File
@@ -0,0 +1,244 @@
/**
* B2B admin WRITE paths (D19 step 2) — the money-touching operations the
* wholesale desk performs daily, which a read-only scan cannot cover:
*
* place an order for a customer from the admin panel
* record a payment document (cash) and confirm it
*
* Both move a customer's gold/rial debt, so each assertion checks the LEDGER,
* not just the HTTP status. Everything created here is removed again, and the
* customer's balances are compared before and after to prove nothing was left
* behind.
*
* Usage: node scripts/smoke-admin-b2b-writes.cjs [apiBaseUrl]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
let pass = 0;
let fail = 0;
const failures = [];
const chk = (c, m) => {
if (c) {
pass++;
console.log(' PASS ' + m);
} else {
fail++;
failures.push(m);
console.log(' FAIL ' + m);
}
};
const step = (t) => console.log('\n' + t);
let H = {};
const call = async (method, p, body) => {
const res = await fetch(`${API}/${p}`, {
method,
headers: H,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let payload = null;
try {
payload = JSON.parse(text);
} catch {
/* empty */
}
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
const createdOrders = [];
const createdEntries = [];
(async () => {
const login = await fetch(`${API}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
});
const auth = await login.json();
if (!auth.api_token) {
console.log('login failed');
process.exit(1);
}
H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token };
// A WHOLESALE customer — retail is out of scope (D13).
const customer = await prisma.customer.findFirst({
where: { kind: { not: 'RETAIL' }, isActive: true, isArchived: false },
select: { id: true, fullName: true, goldDebtGrams: true, fiatDebtRial: true },
});
if (!customer) {
console.log('no wholesale customer available');
process.exit(1);
}
console.log(`Customer under test: ${customer.fullName}`);
console.log(` gold debt before: ${customer.goldDebtGrams} rial debt before: ${customer.fiatDebtRial}`);
const variant = await prisma.productVariant.findFirst({
where: { OR: [{ qty: { gt: 0 } }, { isPreOrder: true }] },
select: { id: true, title: true, rashWeightGrams: true, defaultWagePercent: true },
});
if (!variant) {
console.log('no orderable variant available');
process.exit(1);
}
// ── 1. ADMIN PLACES AN ORDER ────────────────────────────────────────────
step('1 — PLACE A WHOLESALE ORDER FROM THE ADMIN PANEL');
const wage = Number(variant.defaultWagePercent);
const order = await call('POST', 'admin/orders/create', {
customer_id: customer.id,
order_items: [{ vid: variant.id, qty: 2, percent: wage }],
note: 'سفارش آزمایشی تحویل',
});
chk(order.ok, `POST admin/orders/create -> ${order.status}` + (order.ok ? '' : ` ${order.raw.slice(0, 200)}`));
const orderId = order.body?.id ?? order.body?.order?.id;
if (orderId) createdOrders.push(orderId);
chk(Boolean(orderId), 'an order id came back');
if (orderId) {
const row = await prisma.order.findUnique({
where: { id: orderId },
include: { items: true },
});
chk(row?.customerId === customer.id, 'the order is attached to the chosen customer');
chk(row?.items.length === 1, 'the line item was saved');
chk(row?.items[0]?.quantity === 2, 'the quantity (2) was saved');
// The reference formula, per unit: rash * (1 + (wage - discount)/100)
const rash = Number(variant.rashWeightGrams);
const expectedPerUnit = rash * (1 + wage / 100);
const actualPerUnit = Number(row?.items[0]?.computedWeightGrams ?? 0);
chk(
Math.abs(actualPerUnit - expectedPerUnit) < 0.00005,
`making charge per unit: ${rash}g +${wage}% = ${expectedPerUnit.toFixed(4)} (got ${actualPerUnit})`,
);
chk(
Math.abs(Number(row?.totalGoldWeightGrams ?? 0) - expectedPerUnit * 2) < 0.00005,
`the order total is the per-unit weight x2 (${(expectedPerUnit * 2).toFixed(4)})`,
);
step('1b — the order appears in the wholesale grid');
const grid = await call('POST', 'admin/orders/wholesale/list', { page: 1, limit: 50 });
chk(grid.ok, `POST admin/orders/wholesale/list -> ${grid.status}`);
chk(
(grid.body?.rows ?? []).some((r) => r.id === orderId),
'the new order is listed',
);
step('1c — its detail opens');
const detail = await call('GET', `admin/orders/${orderId}`);
chk(detail.ok, `GET admin/orders/:id -> ${detail.status}`);
chk(Array.isArray(detail.body?.activity), 'the status timeline came back');
}
// ── 2. RECORD A PAYMENT DOCUMENT ────────────────────────────────────────
step('2 — RECORD A CASH PAYMENT DOCUMENT');
const before = await prisma.customer.findUnique({
where: { id: customer.id },
select: { goldDebtGrams: true, fiatDebtRial: true },
});
// type 4 = «پولی» (cash), which takes `price` + `price_per_gram`
// (addFinDoc.vue:33,104-115). Type 2 is «حواله» and takes a weight in
// `value` — sending cash fields with type 2 yields a zero amount and a 400.
const doc = await call('POST', 'admin/documents', {
type: 4,
customer_id: customer.id,
price: 10000000,
price_per_gram: 50000000,
note: 'پرداخت آزمایشی تحویل',
});
chk(doc.ok, `POST admin/documents -> ${doc.status}` + (doc.ok ? '' : ` ${doc.raw.slice(0, 200)}`));
const docId = doc.body?.id;
if (docId) createdEntries.push(docId);
chk(Boolean(docId), 'a document id came back');
if (docId) {
const entry = await prisma.ledgerEntry.findUnique({ where: { id: docId } });
chk(Boolean(entry), 'a ledger entry was written');
chk(entry?.customerId === customer.id, 'it is attached to the right customer');
chk(entry?.confirmed === false, 'it starts UNCONFIRMED, as the reference does');
step('2b — it appears in the documents grid');
const dlist = await call('POST', 'admin/documents/list', { page: 1, limit: 50 });
chk(dlist.ok, `POST admin/documents/list -> ${dlist.status}`);
chk((dlist.body?.rows ?? []).some((r) => r.id === docId), 'the new document is listed');
step('2c — CONFIRM it, and the customer balance moves');
const conf = await call('POST', `admin/documents/${docId}/confirm`);
chk(conf.ok, `POST admin/documents/:id/confirm -> ${conf.status}`);
const afterConfirm = await prisma.ledgerEntry.findUnique({ where: { id: docId } });
chk(afterConfirm?.confirmed === true, 'the entry is now confirmed');
const balAfter = await prisma.customer.findUnique({
where: { id: customer.id },
select: { goldDebtGrams: true, fiatDebtRial: true },
});
const moved =
String(balAfter?.goldDebtGrams) !== String(before?.goldDebtGrams) ||
String(balAfter?.fiatDebtRial) !== String(before?.fiatDebtRial);
chk(moved, `confirming moved the balance (gold ${before?.goldDebtGrams} -> ${balAfter?.goldDebtGrams})`);
step('2d — confirm is a TOGGLE, as the reference has it');
const unconf = await call('POST', `admin/documents/${docId}/confirm`);
chk(unconf.ok, `second confirm -> ${unconf.status}`);
const toggled = await prisma.ledgerEntry.findUnique({ where: { id: docId } });
chk(toggled?.confirmed === false, 'it toggled back to unconfirmed');
const balBack = await prisma.customer.findUnique({
where: { id: customer.id },
select: { goldDebtGrams: true, fiatDebtRial: true },
});
chk(
String(balBack?.goldDebtGrams) === String(before?.goldDebtGrams) &&
String(balBack?.fiatDebtRial) === String(before?.fiatDebtRial),
'un-confirming restored the balance exactly',
);
}
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
if (createdEntries.length) {
await prisma.ledgerEntry.deleteMany({ where: { id: { in: createdEntries } } });
}
if (createdOrders.length) {
await prisma.orderItem.deleteMany({ where: { orderId: { in: createdOrders } } });
await prisma.orderStatusEvent.deleteMany({ where: { orderId: { in: createdOrders } } });
await prisma.ledgerEntry.deleteMany({ where: { orderId: { in: createdOrders } } });
await prisma.order.deleteMany({ where: { id: { in: createdOrders } } });
}
const finalBal = await prisma.customer.findUnique({
where: { id: customer.id },
select: { goldDebtGrams: true, fiatDebtRial: true },
});
chk(
String(finalBal?.goldDebtGrams) === String(customer.goldDebtGrams) &&
String(finalBal?.fiatDebtRial) === String(customer.fiatDebtRial),
`the customer's balances are exactly as they started ` +
`(gold ${finalBal?.goldDebtGrams}, rial ${finalBal?.fiatDebtRial})`,
);
console.log(
` deleted ${createdOrders.length} order(s) and ${createdEntries.length} ledger entry/entries ` +
'created by this run. No pre-existing data was removed.',
);
console.log(`\n${'='.repeat(60)}`);
console.log(`B2B WRITE SMOKE: passed ${pass} failed ${fail}`);
if (fail) {
console.log('\nFailures:');
failures.forEach((f) => console.log(' - ' + f));
}
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});
+308
View File
@@ -0,0 +1,308 @@
/**
* Delivery smoke test — the admin flows the owner listed (D19 step 3):
*
* login → create a customer → create and edit a sales expert
* → create and edit a product with variants
* → view an order and change its status
*
* This is a WORKING check, not a parity audit: each step asserts that the
* operation actually took effect in the database, not merely that the HTTP
* call returned 200. A 200 with a silently-dropped field is exactly the class
* of bug this is meant to catch.
*
* Every Persian string is a literal in this file and travels as UTF-8 JSON —
* never as a shell argument (D15).
*
* Usage: node scripts/smoke-admin.cjs [apiBaseUrl]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
let pass = 0;
let fail = 0;
const failures = [];
const chk = (cond, msg) => {
if (cond) {
pass++;
console.log(' PASS ' + msg);
} else {
fail++;
failures.push(msg);
console.log(' FAIL ' + msg);
}
};
const step = (t) => console.log('\n' + t);
/** Records created by this run, removed at the end. */
const created = { customers: [], adminUsers: [], products: [] };
let H = {};
const call = async (method, path, body) => {
const res = await fetch(`${API}/${path}`, {
method,
headers: H,
body: body === undefined ? undefined : JSON.stringify(body),
});
let payload = null;
try {
payload = await res.json();
} catch {
/* empty body */
}
return { status: res.status, ok: res.ok, body: payload };
};
(async () => {
// ── 1. LOGIN ────────────────────────────────────────────────────────────
step('1 — ADMIN LOGIN');
const login = await fetch(`${API}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
});
const auth = await login.json();
chk(login.status === 200 || login.status === 201, `POST admin/auth/login -> ${login.status}`);
chk(typeof auth.api_token === 'string', 'a token came back');
if (!auth.api_token) {
console.log('\nCannot continue without a token.');
process.exit(1);
}
H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token };
const unique = String(Date.now()).slice(-7);
// ── 2. CREATE A CUSTOMER ────────────────────────────────────────────────
step('2 — CREATE A WHOLESALE CUSTOMER');
const custMobile = '0912' + unique;
const cust = await call('POST', 'admin/customers-omde/create', {
fname: 'سمیرا',
lname: 'کریمی',
name: 'سمیرا کریمی',
mobile: custMobile,
percent: 3,
type: 2,
store_name: 'گالری سمیرا',
gram_level: 150,
});
chk(cust.ok, `POST customers-omde/create -> ${cust.status}`);
const custId = cust.body?.id;
if (custId) created.customers.push(custId);
chk(Boolean(custId), 'an id came back');
if (custId) {
const row = await prisma.customer.findUnique({ where: { id: custId } });
chk(row?.firstName === 'سمیرا' && row?.lastName === 'کریمی', 'the name parts were stored separately');
chk(row?.fullName === 'سمیرا کریمی', 'fullName was rebuilt by the trigger');
chk(String(row?.makingChargeOffsetPercent) === '3', 'percent persisted as 3');
chk(row?.storeName === 'گالری سمیرا', 'store_name persisted');
}
step('2b — Persian digits in the mobile are normalised (D19 gap 2)');
const faMobile = '۰۹۱۳' + unique;
const faCust = await call('POST', 'admin/customers-omde/create', {
fname: 'نگین',
lname: 'مرادی',
mobile: faMobile,
percent: 3,
type: 2,
});
chk(faCust.ok, `create with Persian digits -> ${faCust.status}`);
if (faCust.body?.id) {
created.customers.push(faCust.body.id);
const row = await prisma.customer.findUnique({ where: { id: faCust.body.id } });
chk(row?.mobile === '0913' + unique, `stored as ASCII "${row?.mobile}" (not Persian digits)`);
}
// ── 3. SALES EXPERT: CREATE, READ BACK, EDIT ────────────────────────────
step('3 — CREATE A SALES EXPERT');
const expMobile = '0914' + unique;
const exp = await call('POST', 'admin/users', {
fname: 'محمد علی',
lname: 'احمدی',
name: 'محمد علی احمدی',
mobile: expMobile,
password: 'Test12345',
role: 'SALES_EXPERT',
percent: 4.5,
gram_level: 250,
});
chk(exp.ok, `POST admin/users -> ${exp.status}`);
const expId = exp.body?.id;
if (expId) created.adminUsers.push(expId);
chk(Boolean(expId), 'an id came back');
if (expId) {
const row = await prisma.adminUser.findUnique({
where: { id: expId },
include: { salesExpertProfile: true },
});
chk(row?.firstName === 'محمد علی', 'a two-token first name was NOT re-split');
chk(row?.lastName === 'احمدی', 'lastName stored');
chk(Boolean(row?.salesExpertProfile), 'the SalesExpertProfile was auto-created');
step('3b — EDIT the sales expert (one field only)');
const edit = await call('PUT', `admin/users/${expId}`, { gram_level: 999 });
chk(edit.ok, `PUT admin/users/:id -> ${edit.status}`);
const after = await prisma.adminUser.findUnique({
where: { id: expId },
include: { salesExpertProfile: true },
});
chk(String(after?.salesExpertProfile?.gramLevel) === '999', 'gram_level was persisted');
chk(after?.firstName === 'محمد علی' && after?.lastName === 'احمدی', 'the name was untouched');
const grid = await call('POST', 'admin/customers-omde/reagent/list', { page: 1, limit: 200 });
chk(grid.ok, `POST reagent/list -> ${grid.status}`);
const mine = (grid.body?.rows ?? []).find((r) => r.id === expId);
chk(Boolean(mine), 'the expert appears in the grid');
chk(mine?.fname === 'محمد علی' && mine?.lname === 'احمدی', 'the grid shows the real name parts');
}
// ── 4. PRODUCT + VARIANTS: CREATE, READ BACK, EDIT ──────────────────────
step('4 — CREATE A PRODUCT WITH VARIANTS');
const prod = await call('POST', 'admin/products/create', {
title: 'انگشتر طلا ' + unique,
body_html: 'توضیح آزمایشی',
product_code: 'SMK-' + unique,
show_type: 3,
is_active: true,
variants: [
{ title: 'سایز ۱', grams: 2.5, percentage_pay: 7, qty: 4 },
{ title: 'سایز ۲', grams: 3.25, percentage_pay: 7, qty: 0, is_pre_order: true },
],
});
chk(prod.ok, `POST admin/products/create -> ${prod.status}`);
const prodId = prod.body?.id;
if (prodId) created.products.push(prodId);
chk(Boolean(prodId), 'an id came back');
if (prodId) {
const variants = await prisma.productVariant.findMany({
where: { productId: prodId },
orderBy: { title: 'asc' },
});
chk(variants.length === 2, `both variants were created (got ${variants.length})`);
chk(
variants.some((v) => String(v.rashWeightGrams) === '2.5'),
'the 2.5g weight round-tripped exactly (no float drift)',
);
chk(
variants.some((v) => String(v.defaultWagePercent) === '7'),
'the making charge (اجرت) persisted as 7',
);
chk(variants.every((v) => Boolean(v.sku)), 'every variant got a SKU');
chk(
variants.some((v) => v.isPreOrder === true),
'the pre-order flag persisted',
);
step('4b — OPEN the product for edit');
const show = await call('GET', `admin/products/${prodId}`);
chk(show.ok, `GET admin/products/:id -> ${show.status}`);
chk(show.body?.title === 'انگشتر طلا ' + unique, 'the title reads back intact');
// The form reads `variants_all` (AddProduct.vue:488), not `variants`.
const shownVariants = show.body?.variants_all ?? [];
chk(shownVariants.length === 2, `the edit form receives both variants (got ${shownVariants.length})`);
step('4c — EDIT the product');
const upd = await call('PUT', `admin/products/${prodId}`, { title: 'انگشتر طلا ویرایش‌شده' });
chk(upd.ok, `PUT admin/products/:id -> ${upd.status}`);
const afterP = await prisma.product.findUnique({ where: { id: prodId } });
chk(afterP?.title === 'انگشتر طلا ویرایش‌شده', 'the new title persisted');
chk(afterP?.productCode === 'SMK-' + unique, 'product_code was NOT wiped by the partial edit');
const stillThere = await prisma.productVariant.count({ where: { productId: prodId } });
chk(stillThere === 2, `the variants survived the edit (${stillThere} of 2)`);
}
// ── 5. ORDERS: LIST, VIEW, CHANGE STATUS ────────────────────────────────
step('5 — ORDER LIST / DETAIL / STATUS');
const orders = await call('POST', 'admin/orders/list', { page: 1, limit: 5 });
chk(orders.ok, `POST admin/orders/list -> ${orders.status}`);
const wholesale = await call('POST', 'admin/orders/wholesale/list', { page: 1, limit: 5 });
chk(wholesale.ok, `POST admin/orders/wholesale/list -> ${wholesale.status}`);
const statusOpts = await call('GET', 'admin/orders/status-options');
chk(statusOpts.ok, `GET admin/orders/status-options -> ${statusOpts.status}`);
const anyOrder =
(orders.body?.rows ?? [])[0] ?? (wholesale.body?.rows ?? [])[0] ?? null;
if (!anyOrder) {
console.log(' (no order exists in this database — detail/status checks skipped)');
} else {
const detail = await call('GET', `admin/orders/${anyOrder.id}`);
chk(detail.ok, `GET admin/orders/:id -> ${detail.status}`);
chk(Boolean(detail.body?.id), 'the order detail came back');
const before = await prisma.order.findUnique({ where: { id: anyOrder.id } });
const options = Array.isArray(statusOpts.body) ? statusOpts.body : [];
// Pick a status that is NOT the current one, so the write is observable.
const targetOpt = options.find(
(o) => String(o.id ?? o.value) !== String(before?.status),
);
if (!targetOpt || !before) {
console.log(' (no alternative status available — status change skipped)');
} else {
const target = String(targetOpt.id ?? targetOpt.value);
// UpdateOrderStatusDto requires `orderId` — the exact field the retail
// List.vue screen was failing to send (fixed in this batch).
const res = await call('POST', 'admin/orders/status', {
orderId: anyOrder.id,
status: target,
});
chk(res.ok, `POST admin/orders/status -> ${res.status}`);
const after = await prisma.order.findUnique({ where: { id: anyOrder.id } });
chk(String(after?.status) === target, `the status actually changed to ${target}`);
// Put it back exactly as it was — this is live data, not a fixture.
await prisma.order.update({
where: { id: anyOrder.id },
data: { status: before.status },
});
const restored = await prisma.order.findUnique({ where: { id: anyOrder.id } });
chk(restored?.status === before.status, 'the original status was restored');
}
}
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP — removing only the rows this run created');
if (created.products.length) {
await prisma.productVariant.deleteMany({ where: { productId: { in: created.products } } });
await prisma.productImage.deleteMany({ where: { productId: { in: created.products } } });
await prisma.product.deleteMany({ where: { id: { in: created.products } } });
}
if (created.adminUsers.length) {
await prisma.salesExpertProfile.deleteMany({
where: { adminUserId: { in: created.adminUsers } },
});
await prisma.adminUser.deleteMany({ where: { id: { in: created.adminUsers } } });
}
if (created.customers.length) {
await prisma.customer.deleteMany({ where: { id: { in: created.customers } } });
}
console.log(
` deleted: ${created.products.length} product(s) + their variants/images, ` +
`${created.adminUsers.length} admin user(s) + profiles, ` +
`${created.customers.length} customer(s). No pre-existing data was removed.`,
);
console.log(`\n${'='.repeat(60)}`);
console.log(`ADMIN SMOKE: passed ${pass} failed ${fail}`);
if (fail) {
console.log('\nFailures:');
failures.forEach((f) => console.log(' - ' + f));
}
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nSMOKE ABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});
+283
View File
@@ -0,0 +1,283 @@
/**
* Delivery smoke test — the customer-app flows the owner listed (D19 step 3):
*
* login with the master OTP → see products → add to cart → place an order
* → see my orders
*
* The customer app talks to ONE API through `api`
* (apps/user-app/src/api/client.ts). Cart stock rules are asserted against
* that app's real cartStore source on disk.
*
* Requires the API to be running WITH OTP_MASTER_CODE set (see below).
* Every Persian string is a literal here, never a shell argument (D15).
*
* Usage: node scripts/smoke-apps.cjs [apiBaseUrl] [masterCode]
*/
const fs = require('fs');
const path = require('path');
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
const MASTER = process.argv[3] || '778899';
let pass = 0;
let fail = 0;
const failures = [];
const chk = (cond, msg) => {
if (cond) {
pass++;
console.log(' PASS ' + msg);
} else {
fail++;
failures.push(msg);
console.log(' FAIL ' + msg);
}
};
const step = (t) => console.log('\n' + t);
/** Extra notes reported at the end (kept for callers that push here). */
const divergences = [];
const createdOrders = [];
let token = null;
const H = () => ({
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
});
const call = async (method, p, body) => {
const res = await fetch(`${API}/${p}`, {
method,
headers: H(),
body: body === undefined ? undefined : JSON.stringify(body),
});
let payload = null;
try {
payload = await res.json();
} catch {
/* empty */
}
return { status: res.status, ok: res.ok, body: payload };
};
// ── The cart rules, evaluated against each app's OWN source ────────────────
//
// The stores are Pinia modules, so rather than boot Pinia we extract the two
// pure functions the stock ceiling depends on and run them directly. If either
// app's source stops defining them, that is itself the failure.
function loadCartRules(app) {
const file = path.join(__dirname, '..', '..', app, 'src', 'stores', 'cartStore.ts');
const src = fs.readFileSync(file, 'utf8');
const capBody = src.match(/capQty\(want: number, variant: \{[^}]*\}\) \{([\s\S]*?)\n \}/);
const guard = /const target = data\.product\?\.variants\?\.find/.test(src);
const clamp = /if \(field === 'qty'\) \{/.test(src);
return {
src,
hasCapQty: Boolean(capBody),
hasMaxQty: /maxQty\(item: Pick<CartItem, 'pid' \| 'vid'>\)/.test(src),
hasAddGuard: guard,
hasQtyClamp: clamp,
// Rebuild capQty from the app's own body so the rule under test is the
// shipped one, not a copy written here.
capQty: capBody
? new Function('want', 'variant', capBody[1].replace(/this\./g, ''))
: null,
};
}
(async () => {
// ── 0. USER-APP CART RULES ────────────────────────────────────────────
step('0 — CART STOCK RULES, in user-app source');
const v1 = loadCartRules('user-app');
chk(v1.hasCapQty, 'user-app: capQty() exists');
chk(v1.hasMaxQty, 'user-app: maxQty() exists');
chk(v1.hasAddGuard, 'user-app: addToCart refuses an un-orderable variant');
chk(v1.hasQtyClamp, 'user-app: updateFeild clamps a qty change');
if (v1.capQty) {
const cases = [
{ want: 10, variant: { qty: 3 }, expect: 3, label: 'want 10 of 3 in stock -> 3' },
{ want: 2, variant: { qty: 5 }, expect: 2, label: 'want 2 of 5 in stock -> 2' },
{ want: 99, variant: { qty: 0, isPreOrder: true }, expect: 99, label: 'pre-order is uncapped' },
{ want: 0, variant: { qty: 5 }, expect: 1, label: 'a zero quantity floors at 1' },
{ want: -4, variant: { qty: 5 }, expect: 1, label: 'a negative quantity floors at 1' },
];
for (const c of cases) {
const a = v1.capQty(c.want, c.variant);
chk(a === c.expect, `user-app: ${c.label} (got ${a})`);
}
}
// ── 1. LOGIN WITH THE MASTER CODE ───────────────────────────────────────
step('1 — LOGIN with the delivery master code');
const customer = await prisma.customer.findFirst({
where: { isActive: true, isArchived: false },
select: { id: true, mobile: true, fullName: true },
});
if (!customer) {
console.log(' no active customer in this database — cannot continue');
process.exit(1);
}
console.log(` (as ${customer.fullName} / ${customer.mobile})`);
const verify = await call('POST', 'customer/auth/verify-otp', {
username: customer.mobile,
password: MASTER,
});
chk(verify.ok, `POST customer/auth/verify-otp -> ${verify.status}`);
if (!verify.body?.token) {
console.log('\n The master code was refused. Start the API with OTP_MASTER_CODE set.');
await prisma.$disconnect();
process.exit(1);
}
token = verify.body.token;
chk(true, 'a customer token came back');
const me = await call('GET', 'customer/me');
chk(me.ok, `GET customer/me -> ${me.status}`);
chk(me.body?.mobile === customer.mobile, 'it is THIS customer');
// ── 2. SEE PRODUCTS ─────────────────────────────────────────────────────
step('2 — SEE PRODUCTS');
const products = await call('GET', 'catalog/products');
chk(products.ok, `GET catalog/products -> ${products.status}`);
const list = Array.isArray(products.body) ? products.body : [];
chk(list.length > 0, `the catalogue is not empty (${list.length} products)`);
const withVariant = list.find((p) => (p.variants ?? []).length > 0);
chk(Boolean(withVariant), 'at least one product has a variant');
if (withVariant) {
const v = withVariant.variants[0];
chk(typeof v.rashWeightGrams === 'string', 'weights arrive as strings (no float drift)');
chk(v.qty !== undefined, 'variants carry qty, so the apps can cap the cart');
chk(v.isPreOrder !== undefined, 'variants carry isPreOrder');
}
const price = await call('GET', 'pricing/latest?karatPerMille=750');
chk(price.ok, `GET pricing/latest -> ${price.status}`);
// ── 3. ADD TO CART (the rule both apps apply before ordering) ───────────
step('3 — ADD TO CART');
// Pick something actually orderable, exactly as the apps' own guard does.
let chosen = null;
for (const p of list) {
const v = (p.variants ?? []).find((x) => (x.qty ?? 0) > 0 || x.isPreOrder);
if (v) {
chosen = { product: p, variant: v };
break;
}
}
chk(Boolean(chosen), 'found an orderable variant to order');
if (!chosen) {
console.log('\n No orderable variant exists — cannot place an order.');
await prisma.$disconnect();
process.exit(1);
}
console.log(` (chose "${chosen.product.title}" / "${chosen.variant.title}")`);
const ceiling = chosen.variant.isPreOrder ? null : chosen.variant.qty;
if (v1.capQty) {
const want = 999;
const capped = v1.capQty(want, chosen.variant);
chk(
ceiling === null ? capped === want : capped === ceiling,
`the cart caps ${want} at ${ceiling === null ? 'uncapped (pre-order)' : ceiling}`,
);
}
// ── 4. PLACE AN ORDER ───────────────────────────────────────────────────
step('4 — PLACE AN ORDER');
const qty = 1;
const order = await call('POST', 'orders', {
items: [
{
productVariantId: chosen.variant.id,
quantity: qty,
wagePercent: Number(chosen.variant.defaultWagePercent),
},
],
});
chk(order.ok, `POST orders -> ${order.status}`);
const orderId = order.body?.id;
if (orderId) createdOrders.push(orderId);
chk(Boolean(orderId), 'an order id came back');
chk(Boolean(order.body?.orderNumber), 'it has an order number');
if (orderId) {
const row = await prisma.order.findUnique({
where: { id: orderId },
include: { items: true },
});
chk(row?.customerId === customer.id, 'the order belongs to the TOKEN customer, not a body id');
chk(row?.items.length === 1, 'the item was saved');
// The reference formula: computed = rash * (1 + (wage - discount)/100)
const rash = Number(chosen.variant.rashWeightGrams);
const wage = Number(chosen.variant.defaultWagePercent);
const expected = rash * (1 + wage / 100);
const actual = Number(row?.items[0]?.computedWeightGrams ?? 0);
chk(
Math.abs(actual - expected) < 0.00005,
`the making charge was applied: ${rash}g +${wage}% = ${expected.toFixed(4)} (got ${actual})`,
);
}
step('4b — NEGATIVE: an order with no items is rejected');
const empty = await call('POST', 'orders', { items: [] });
chk(empty.status === 400, `POST orders with items:[] -> ${empty.status} (expected 400)`);
step('4c — NEGATIVE: ordering without a token is rejected');
const saved = token;
token = null;
const anon = await call('POST', 'orders', {
items: [{ productVariantId: chosen.variant.id, quantity: 1, wagePercent: 7 }],
});
chk(anon.status === 401, `POST orders unauthenticated -> ${anon.status} (expected 401)`);
token = saved;
// ── 5. SEE MY ORDERS ────────────────────────────────────────────────────
step('5 — SEE MY ORDERS');
const mine = await call('GET', 'customer/me/orders');
chk(mine.ok, `GET customer/me/orders -> ${mine.status}`);
const rows = Array.isArray(mine.body) ? mine.body : [];
chk(rows.some((o) => o.id === orderId), 'the new order appears in my orders');
const ledger = await call('GET', 'customer/me/ledger-entries');
chk(ledger.ok, `GET customer/me/ledger-entries -> ${ledger.status}`);
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
if (createdOrders.length) {
await prisma.orderItem.deleteMany({ where: { orderId: { in: createdOrders } } });
await prisma.orderStatusEvent.deleteMany({ where: { orderId: { in: createdOrders } } });
await prisma.ledgerEntry.deleteMany({ where: { orderId: { in: createdOrders } } });
await prisma.order.deleteMany({ where: { id: { in: createdOrders } } });
}
console.log(
` deleted ${createdOrders.length} order(s) this run created, with their items, ` +
`status events and ledger entries. No pre-existing data was removed.`,
);
console.log(`\n${'='.repeat(60)}`);
console.log(`APPS SMOKE: passed ${pass} failed ${fail}`);
if (fail) {
console.log('\nFailures:');
failures.forEach((f) => console.log(' - ' + f));
}
if (divergences.length) {
console.log(`\nNotes:\n${divergences.map((d) => ' - ' + d).join('\n')}`);
}
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nSMOKE ABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});
+254
View File
@@ -0,0 +1,254 @@
/**
* Accounting integration (D27) — end to end against Tahesab's PUBLIC TEST
* server (https://api-test.tahesab.ir), on a THROWAWAY database.
*
* Runs the real services (Nest application context), not mocks:
* 1. config: provider switch, token sealed, generic `const` routes fenced off
* 2. connection test
* 3. F1 verified customer → pushed (searched by mobile first, so re-runs reuse
* the same test account) → linked
* 4. F1 customer pull (one group only) imports accounts into the app
* 5. F2 balance pull + the figure /customer/me now shows
* 6. F5 a product with no image is not in the catalog and cannot be ordered
* 7. F3 an order reaching DELIVERED_TO_AGENT queues a document; the worker
* posts it as TEMPORARY; the rows in Tahesab move exactly the order's
* gold (karat-with-charge rule)
* 8. F6 «گزارش» reads the Tahesab statement
* 9. F3 cancelling the order deletes the (still temporary) document
* 10. F4 item pull: Tahesab items become hidden products
*
* The document is created temporary and deleted again; the only lasting trace
* on the test server is one test account (mobile 09990001111) that later runs
* reuse.
*
* Usage (database name must contain "test" or "e2e" — it is written freely):
* DATABASE_URL=postgresql://…/hedenagold_acc_e2e TAHESAB_TEST_TOKEN=<token from the docs page> \
* ACCOUNTING_SECRET_KEY=x npx ts-node --transpile-only scripts/test-accounting-tahesab.ts
*/
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { OrderStatus, Prisma, PrismaClient, VerificationStep } from '@prisma/client';
process.env.ACCOUNTING_WORKER = 'off';
process.env.IAM_ENABLED = 'false';
const DB = process.env.DATABASE_URL ?? '';
const TOKEN = process.env.TAHESAB_TEST_TOKEN ?? '';
if (!/test|e2e/i.test(new URL(DB.replace(/^postgresql/, 'http')).pathname)) {
console.error('Refusing: DATABASE_URL must point to a throwaway database (name containing "test" or "e2e").');
process.exit(2);
}
if (!TOKEN) {
console.error('TAHESAB_TEST_TOKEN is required (the bearer token shown on https://tahesab.ir/APiDoc/).');
process.exit(2);
}
let pass = 0;
let fail = 0;
const chk = (c: unknown, m: string) => {
if (c) {
pass += 1;
console.log(` PASS ${m}`);
} else {
fail += 1;
console.log(` FAIL ${m}`);
}
};
const step = (t: string) => console.log(`\n${t}`);
async function main() {
const prisma0 = new PrismaClient();
const { runMigrations } = await import('../src/prisma/migrate');
await runMigrations(prisma0);
await prisma0.$disconnect();
const { AppModule } = await import('../src/app.module');
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] });
const { PrismaService } = await import('../src/prisma/prisma.service');
const { AccountingConfigService } = await import('../src/modules/accounting-sync/accounting-config.service');
const { AccountingAdminService } = await import('../src/modules/accounting-sync/accounting-admin.service');
const { AccountingSyncService, factorCodeFor } = await import('../src/modules/accounting-sync/accounting-sync.service');
const { AccountingWorkerService } = await import('../src/modules/accounting-sync/accounting-worker.service');
const { AdminCustomersService } = await import('../src/modules/admin-customers/admin-customers.service');
const { AdminMiscService } = await import('../src/modules/admin-misc/admin-misc.service');
const { OrdersService } = await import('../src/modules/orders/orders.service');
const { CatalogService } = await import('../src/modules/catalog/catalog.service');
const { CustomersService } = await import('../src/modules/customers/customers.service');
const { CustomerReportService } = await import('../src/modules/customer-report/customer-report.service');
const prisma = app.get(PrismaService);
const config = app.get(AccountingConfigService);
const admin = app.get(AccountingAdminService);
const sync = app.get(AccountingSyncService);
const worker = app.get(AccountingWorkerService);
const customersAdmin = app.get(AdminCustomersService);
const misc = app.get(AdminMiscService);
const orders = app.get(OrdersService);
const catalog = app.get(CatalogService);
const customers = app.get(CustomersService);
const report = app.get(CustomerReportService);
// ── 1 ──
step('1. configuration');
await config.save({
provider: 'tahesab',
tahesab: { baseUrl: 'https://api-test.tahesab.ir', dbName: 'TahesabDB', token: TOKEN, timeoutMs: 30000 },
features: { customers: true, balances: true, report: true, products: true, documents: true },
mapping: { importCustomerGroups: [{ id: '0', name: 'مشتري' }], newCustomerGroup: { id: '2', name: 'بنکدار' }, karat: 750 },
});
const pub = await config.getPublic();
chk(pub.provider === 'tahesab', 'provider switch saved');
chk(pub.tahesab.tokenSet && pub.tahesab.tokenReadable, 'token stored and readable by the server');
chk(!JSON.stringify(pub).includes(TOKEN), 'the admin view never contains the token');
const row = await prisma.setting.findUnique({ where: { key: 'accounting.config' } });
chk(!JSON.stringify(row?.value).includes(TOKEN), 'the token is not stored in plain text');
const consts = await misc.listConsts();
chk(!consts.some((c: { key: string }) => c.key.startsWith('accounting.')), 'GET const does not list accounting.* rows');
// ── 2 ──
step('2. connection test');
const t = await admin.test('tahesab');
chk(t.ok, `health: ${JSON.stringify(t).slice(0, 160)}`);
// ── 3 ──
step('3. F1 push a verified customer');
const mobile = '09990001111';
const customer = await prisma.customer.upsert({
where: { mobile },
update: {},
create: { fullName: 'hedena-gold smoke', firstName: 'hedena-gold', lastName: 'smoke', mobile, nationalCode: null, passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN' },
});
await prisma.customer.update({ where: { id: customer.id }, data: { verificationStep: VerificationStep.STEP_TWO_SUBMITTED } });
await customersAdmin.verify({ customer_id: customer.id, sts_verify_step_two: 2 });
const queued = await prisma.accountingOutbox.findFirst({ where: { kind: 'CUSTOMER_PUSH', entityId: customer.id } });
chk(queued?.status === 'PENDING', 'VERIFIED queues a CUSTOMER_PUSH in the same transaction');
await worker.tick('outbox');
const pushed = await prisma.accountingOutbox.findUnique({ where: { id: queued!.id } });
chk(pushed?.status === 'SENT', `push sent (${pushed?.status} ${pushed?.lastError ?? ''})`);
const link = await prisma.accountingLink.findFirst({ where: { entityType: 'CUSTOMER', entityId: customer.id } });
chk(!!link?.externalCode, `customer linked to Tahesab account ${link?.externalCode} (${link?.origin})`);
// ── 4 ──
step('4. F1 pull (GID 0 «مشتري» only)');
const before = await prisma.customer.count();
const pulled = await sync.pullCustomers((await config.active())!);
const after = await prisma.customer.count();
chk(pulled.scanned > 0, `scanned ${pulled.scanned} accounts, ${pulled.inScope} in scope`);
chk(after - before === pulled.imported, `imported ${pulled.imported}, linked ${pulled.linked}, issues ${pulled.issues}`);
const imported = await prisma.accountingLink.findFirst({ where: { origin: 'IMPORTED' } });
if (imported) {
const c = await prisma.customer.findUnique({ where: { id: imported.entityId } });
chk(c?.mobile?.startsWith('09') && c.passwordHash === '!OTP_ONLY_NO_PASSWORD_LOGIN' && c.goldCreditLimitGrams.eq(0), 'imported customer keyed by mobile, OTP-only, credit 0');
}
// ── 5 ──
step('5. F2 balances');
const b = await sync.pullBalances((await config.active())!);
chk(b.updated > 0, `balances updated for ${b.updated}/${b.linked} linked customers`);
const me = await customers.getOrThrow(customer.id);
chk(me.balanceSource === 'accounting', `/customer/me shows the accounting balance (${String(me.goldDebtGrams)} g)`);
// ── 6 ──
step('6. F5 no image, no app');
const bare = await prisma.product.create({
data: { title: 'gb-e2e-noimage', variants: { create: { title: 'v', rashWeightGrams: 1, defaultWagePercent: 10, qty: 5 } } },
include: { variants: true },
});
chk(!(await catalog.listProducts()).some((p) => p.id === bare.id), 'imageless product is not in the catalog');
let refused = false;
try {
await catalog.getVariantOrThrow(bare.variants[0].id);
} catch {
refused = true;
}
chk(refused, 'imageless product cannot be ordered');
// ── 7 ──
step('7. F3 order document');
const product = await prisma.product.create({
data: {
title: 'gb-e2e-انگشتر',
images: { create: { url: '/uploads/e2e.jpg' } },
variants: {
create: [
{ title: '4.2g', rashWeightGrams: '4.2', defaultWagePercent: 10, qty: 10 },
{ title: '10g', rashWeightGrams: '10', defaultWagePercent: 18.5, qty: 10 },
],
},
},
include: { variants: true },
});
chk((await catalog.listProducts()).some((p) => p.id === product.id), 'product with an image is in the catalog');
const order = await orders.createOrder(
{
items: [
{ productVariantId: product.variants[0].id, quantity: 2, wagePercent: 10 },
{ productVariantId: product.variants[1].id, quantity: 1, wagePercent: 18.5, flatRialCharge: 500000 },
],
} as never,
customer.id,
);
await orders.updateStatus(order.id, OrderStatus.DELIVERED_TO_AGENT);
const docJob = await prisma.accountingOutbox.findFirst({ where: { kind: 'ORDER_DOCUMENT', entityId: order.id } });
chk(docJob?.status === 'PENDING', 'delivery queued the document with the debt');
await worker.tick('outbox');
const docSent = await prisma.accountingOutbox.findUnique({ where: { id: docJob!.id } });
chk(docSent?.status === 'SENT', `document posted (${docSent?.status} ${docSent?.lastError ?? ''})`);
const a = (await config.active())!;
const factorCode = factorCodeFor(order.id);
// Whatever happens below, the test document must not stay on the shared server.
cleanup = async () => {
await a.provider.deleteDocument(a.settings, { factorCode, ref: null, customerCode: link!.externalCode }).catch(() => undefined);
};
chk(await a.provider.documentExists(a.settings, { factorCode, ref: null }), `Tahesab finds ${factorCode}`);
const st = await a.provider.getStatement(a.settings, { code: link!.externalCode, from: toJ(), to: toJ() });
const rows = st.filter((r) => r.documentRef === factorCode);
chk(rows.length === 2 && rows.every((r) => r.temporary), `2 temporary rows in Tahesab (${rows.length})`);
const moved = rows.reduce((s, r) => s.add(r.goldOut), new Prisma.Decimal(0));
const total = new Prisma.Decimal(order.totalGoldWeightGrams);
chk(moved.sub(total).abs().lte('0.001'), `rows move ${moved.toFixed(3)} g = order total ${total.toFixed(3)} g`);
// ── 8 ──
step('8. F6 report from Tahesab');
const sum = (await report.summary(customer.id, 1)) as { source?: string; result: { factor_Code: string }[] };
chk(sum.source === 'accounting', 'summary comes from the accounting software');
chk(sum.result.some((r) => r.factor_Code === factorCode), 'the new document is in the statement');
const other = (await report.detail(customer.id, 'not-mine-000000000000')) as { result: unknown[] };
chk(other.result.length === 0, 'SEC-16: an id outside the account matches nothing');
// ── 9 ──
step('9. F3 cancellation removes the temporary document');
await orders.updateStatus(order.id, OrderStatus.CANCELLED);
await worker.tick('outbox');
const del = await prisma.accountingOutbox.findFirst({ where: { kind: 'ORDER_DOCUMENT_DELETE', entityId: order.id } });
chk(del?.status === 'SENT', `delete job sent (${del?.status} ${del?.lastError ?? ''})`);
chk(!(await a.provider.documentExists(a.settings, { factorCode, ref: null })), 'document gone from Tahesab');
// ── 10 ──
step('10. F4 item pull');
const items = await sync.pullProducts(a);
chk(items.items > 0, `items ${items.items}: imported ${items.imported}, linked ${items.linked}, updated ${items.updated}`);
const itemLink = await prisma.accountingLink.findFirst({ where: { entityType: 'PRODUCT', origin: 'IMPORTED' } });
if (itemLink) {
chk(!(await catalog.listProducts()).some((p) => p.id === itemLink.entityId), 'an imported item stays hidden until it gets an image');
}
await cleanup();
await app.close();
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
}
let cleanup: () => Promise<void> = async () => undefined;
function toJ() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { toJalali } = require('../src/modules/accounting-sync/jalali');
return toJalali(new Date());
}
main().catch(async (err) => {
Logger.error(err);
await cleanup();
process.exit(1);
});
+226
View File
@@ -0,0 +1,226 @@
/**
* Accounting integration (D27) — the Zarrin driver against a REAL Zarrin
* installation. No database, no Nest: it calls the driver itself, so it can
* run against the owner's production install without touching the app.
*
* READ-ONLY by default (the install holds real customer data):
* 1. version + account groups (the token is checked by the group listing)
* 2. accounts: no «*» left in names (listhesabha prints «first * last»)
* 3. one account found again by its mobile (searchAccount)
* 4. balances: one and many
* 5. statement: its last running balance equals the account's real balance,
* and a range ending in the past agrees with one ending today
* 6. کارساخته: every page of listKarSakhte (100 per page), groups, stock
* 7. change feed (parLastTenRadif): first poll = cursor only
* 8. item movement (infogardeshsakhte, hand-printed JSON) — Zarrin itself
* sometimes answers 500 here; reported, not failed
*
* WRITE test only with ZARRIN_WRITE_TEST=1 and ZARRIN_TEST_ACCOUNT set — on
* the owner's install that is account 953 «تست اپ گلدبانک» (09990001111),
* created for this on 2026-10-12. Zarrin has no API to delete an account or
* a کارساخته, so this script never creates either:
* 9. edit the test account (its group must survive)
* 10. post a TEMPORARY 2-line document (1 g each, 10 %): the balance must move
* by exactly 2.2 g and the rows read as temporary
* 11. delete it: no rows left, balance and stock back where they were
*
* Usage:
* ZARRIN_URL=http://host:port/ZarrinService/accounting ZARRIN_TOKEN=… \
* [ZARRIN_ACCOUNT=499] [ZARRIN_WRITE_TEST=1 ZARRIN_TEST_ACCOUNT=953] \
* npx ts-node --transpile-only scripts/test-accounting-zarrin.ts
*
* Requests are paced: the owner's install refused connections once when hit
* back to back. The token is never printed.
*/
import { Prisma } from '@prisma/client';
import { toJalali } from '../src/modules/accounting-sync/jalali';
import { ProviderSettings } from '../src/modules/accounting-sync/providers/accounting-provider';
import { zarrinProvider as z } from '../src/modules/accounting-sync/providers/zarrin.provider';
const URL_ = process.env.ZARRIN_URL ?? '';
const TOKEN = process.env.ZARRIN_TOKEN ?? '';
const ACCOUNT = process.env.ZARRIN_ACCOUNT ?? '';
const WRITE = process.env.ZARRIN_WRITE_TEST === '1';
const TEST_ACCOUNT = process.env.ZARRIN_TEST_ACCOUNT ?? '';
if (!URL_ || !TOKEN) {
console.error('ZARRIN_URL and ZARRIN_TOKEN are required.');
process.exit(2);
}
if (WRITE && !TEST_ACCOUNT) {
console.error('ZARRIN_WRITE_TEST=1 needs ZARRIN_TEST_ACCOUNT (a test account the owner approved, e.g. 953).');
process.exit(2);
}
const s: ProviderSettings = { baseUrl: URL_, token: TOKEN, caCert: null, timeoutMs: 60_000, balanceOwesWhen: 'negative' };
const D = (v: string | number) => new Prisma.Decimal(v);
const pause = (ms = 1200) => new Promise((r) => setTimeout(r, ms));
const day = (n: number) => toJalali(new Date(Date.now() - n * 864e5));
let pass = 0;
let fail = 0;
const chk = (c: unknown, m: string) => {
if (c) pass += 1;
else fail += 1;
console.log(` ${c ? 'PASS' : 'FAIL'} ${m}`);
};
const note = (m: string) => console.log(` NOTE ${m}`);
const step = (t: string) => console.log(`\n${t}`);
async function main() {
step('1. connection');
const h = await z.health(s);
chk(h.version !== undefined && Number(h.customerGroups) > 0, `version ${h.version}, ${h.customerGroups} account groups`);
await pause();
step('2. accounts');
const all = (await z.listCustomers(s, null)).items;
chk(all.length > 0, `${all.length} accounts, ${all.filter((c) => c.mobile).length} with a mobile`);
chk(all.every((c) => !c.name.includes('*')), 'no «*» left in any name');
await pause();
const probe = all.find((c) => (ACCOUNT ? c.code === ACCOUNT : !!c.mobile)) ?? all[0];
const code = probe.code;
step(`3. search by mobile (account ${code})`);
if (probe.mobile) {
const found = await z.findCustomersByMobile(s, probe.mobile);
chk(found.some((c) => c.code === code), `found account ${code} by its mobile`);
} else note('account has no mobile; skipped');
await pause();
step('4. balances');
const [one] = await z.getBalances(s, [code]);
chk(!!one, `balance of ${code}: ${one?.goldGrams} g, ${one?.rial} rial (app sign: positive = owes)`);
await pause();
const many = await z.getBalances(s, all.slice(0, 3).map((c) => c.code));
chk(many.length === 3, `3 balances in one call (${many.length})`);
await pause();
step(`5. statement of ${code}`);
const toToday = await z.getStatement(s, { code, from: day(60), to: day(0) });
await pause();
const [now] = await z.getBalances(s, [code]);
const last = toToday[toToday.length - 1];
if (last) {
chk(String(last.goldBalance) === String(now?.goldGrams), `last running balance ${last.goldBalance} = real balance ${now?.goldGrams}`);
await pause();
const past = await z.getStatement(s, { code, from: day(60), to: day(5) });
const byKey = new Map(toToday.map((r) => [r.key, String(r.goldBalance)]));
const shared = past.filter((r) => byKey.has(r.key));
chk(shared.every((r) => byKey.get(r.key) === String(r.goldBalance)), `a range ending 5 days ago agrees on ${shared.length} shared rows`);
note(`${toToday.length} rows in 60 days, ${toToday.filter((r) => r.temporary).length} temporary`);
} else note('no rows in the last 60 days');
await pause();
step('6. کارساخته');
const items = await z.listKarSakhte(s);
chk(items.length > 0, `${items.length} items (pages of 100 read until the last)`);
await pause();
const groups = await z.listKarSakhteGroups(s);
chk(groups.length > 0, `${groups.length} item groups`);
await pause();
const stocked = items.find((i) => i.stockWeight?.gt(0));
if (stocked) {
const st = await z.getKarSakhteStock(s, stocked);
chk(st.weight !== null || st.count !== null, `stock of «${stocked.name}»: ${st.weight ?? st.count}`);
}
await pause();
step('7. change feed');
const first = await z.getChanges(s, null);
chk(!!first.cursor && first.accountCodes.length === 0, `first poll returns only the cursor (${first.cursor})`);
await pause();
step('8. item movement (slow; Zarrin sometimes fails here itself)');
try {
const mv = await z.getKarSakhteMovement(s, day(3), day(0));
chk(true, `${mv.length} rows`);
} catch (err) {
note(`not available right now: ${(err as Error).message.slice(0, 160)}`);
}
if (!WRITE) {
console.log('\nWrite test skipped (set ZARRIN_WRITE_TEST=1 and ZARRIN_TEST_ACCOUNT to run it).');
return;
}
step(`9. edit test account ${TEST_ACCOUNT}`);
const acct = all.find((c) => c.code === TEST_ACCOUNT);
if (!acct) throw new Error(`test account ${TEST_ACCOUNT} not found`);
const parts = acct.name.split(' ');
await z.updateCustomer(s, TEST_ACCOUNT, {
firstName: parts.slice(0, -1).join(' ') || acct.name,
lastName: parts.slice(-1)[0] ?? '',
fullName: acct.name,
mobile: acct.mobile ?? '',
phone: acct.phone,
address: `حساب آزمایشی اتصال اپ — آخرین تست ${new Date().toISOString().slice(0, 10)}`,
nationalCode: null,
group: { id: null, name: acct.groupName },
});
await pause();
const after = (await z.listCustomers(s, null)).items.find((c) => c.code === TEST_ACCOUNT);
chk(after?.groupName === acct.groupName, `group kept: ${after?.groupName}`);
await pause();
step('10–11. temporary document, then delete');
const [b0] = await z.getBalances(s, [TEST_ACCOUNT]);
const sell = items.filter((i) => i.typeName === 'گرمی' && i.stockWeight?.gte(2)).slice(0, 2);
if (sell.length < 2) throw new Error('need two gram items with stock ≥ 2 g');
const stock0: string[] = [];
for (const i of sell) {
await pause();
stock0.push(String((await z.getKarSakhteStock(s, i)).weight));
}
let ref = '';
try {
await pause();
const res = await z.postOrderDocument(
s,
{
factorCode: 'zarrinwritetest',
customerCode: TEST_ACCOUNT,
date: new Date(),
description: 'تست اتصال اپ',
temporary: true,
lines: sell.map((i) => ({ itemCode: i.code, itemName: i.name, weightGrams: D(1), count: 1, wagePercent: D(10), flatRial: D(0), karat: 750, description: `تست اتصال اپ — ${i.name}` })),
},
{},
async (p) => {
if (p.factorId) ref = p.factorId;
},
);
ref = res.ref;
await pause(2000);
const [b1] = await z.getBalances(s, [TEST_ACCOUNT]);
chk(b1 && b0 && b1.goldGrams.sub(b0.goldGrams).eq('2.2'), `invoice ${ref}: balance moved ${b1 && b0 ? b1.goldGrams.sub(b0.goldGrams) : '?'} g (expected 2.2)`);
await pause();
const rows = (await z.getStatement(s, { code: TEST_ACCOUNT, from: day(0), to: day(0) })).filter((r) => r.documentRef === ref);
chk(rows.length > 0 && rows.every((r) => r.temporary), `${rows.length} rows, all temporary`);
} finally {
if (ref) {
await pause(1500);
await z.deleteDocument(s, { factorCode: '', ref, customerCode: TEST_ACCOUNT });
await pause(2000);
const [b2] = await z.getBalances(s, [TEST_ACCOUNT]);
chk(b2 && b0 && b2.goldGrams.eq(b0.goldGrams), `deleted: balance back to ${b2?.goldGrams}`);
await pause();
const left = (await z.getStatement(s, { code: TEST_ACCOUNT, from: day(0), to: day(0) })).filter((r) => r.documentRef === ref);
chk(left.length === 0, `no rows of invoice ${ref} left`);
for (const [n, i] of sell.entries()) {
await pause();
const w = String((await z.getKarSakhteStock(s, i)).weight);
chk(w === stock0[n], `stock of «${i.name}» back to ${stock0[n]} (${w})`);
}
}
}
}
main()
.catch((err) => {
fail += 1;
console.error(`\nABORTED: ${(err as Error).message}`);
})
.finally(() => {
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
});
+308
View File
@@ -0,0 +1,308 @@
/**
* "تکمیل پروفایل" — POST customer/me/complete-profile and its uploads.
*
* Ports henza_server Major/CustomerController::complete
* (app/Http/Controllers/Major/CustomerController.php:73-108).
*
* Covers what the owner asked for per page — opening, the empty state, the
* error state and the main flow — plus the boundaries that matter here:
*
* 1. the main flow: three uploads, then complete, then VERIFIED
* 2. empty/validation: each id missing in turn is a 400/422
* 3. error: an id that names no file is refused, and nothing is written
* 4. the customer comes from the TOKEN — no id is accepted from the body
* 5. no token at all is a 401 on both routes
* 6. an expert can complete their own profile too (D21: every customer
* route works for an expert unchanged)
* 7. SEC-14: extra body fields are IGNORED, not mass-assigned. The
* reference did `$customer->update($request->all())`, which let the app
* write gram_level/percent/active onto itself.
*
* Every Persian string is a literal here and travels as UTF-8 JSON (D15).
*
* Requires the API running WITH OTP_MASTER_CODE set.
* Usage: node scripts/test-complete-profile.cjs [apiBaseUrl] [masterCode]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
const MASTER = process.argv[3] || '1379';
let pass = 0, fail = 0;
const failures = [];
const chk = (c, m) => {
if (c) { pass++; console.log(' PASS ' + m); }
else { fail++; failures.push(m); console.log(' FAIL ' + m); }
};
const step = (t) => console.log('\n' + t);
const call = async (method, p, body, token) => {
const res = await fetch(`${API}/${p}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let payload = null;
try { payload = JSON.parse(text); } catch { /* empty */ }
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
/** A one-pixel PNG, so the upload exercises the real MIME whitelist. */
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const upload = async (token, purpose) => {
const form = new FormData();
form.append('file', new Blob([PNG], { type: 'image/png' }), `${purpose}.png`);
form.append('purpose', purpose);
const res = await fetch(`${API}/customer/me/documents`, {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: form,
});
const text = await res.text();
let payload = null;
try { payload = JSON.parse(text); } catch { /* empty */ }
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
const signIn = (mobile) =>
call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER });
/** Everything this run creates, removed at the end. */
const made = { admins: [], customers: [], files: [] };
const uniq = String(Date.now()).slice(-7);
(async () => {
step('SETUP — an unverified customer, and a sales expert');
const mkCustomer = async (suffix, name) => {
const c = await prisma.customer.create({
data: {
fullName: name,
firstName: name.split(' ')[0],
lastName: name.split(' ').slice(1).join(' '),
mobile: `0917${uniq.slice(0, 3)}${suffix}`,
nationalCode: `nc${uniq}${suffix}`,
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
goldCreditLimitGrams: '100',
verificationStep: 'UNVERIFIED',
},
});
made.customers.push(c.id);
return c;
};
const cust = await mkCustomer('11', 'مشتری مدارک');
console.log(` customer: ${cust.fullName} (${cust.mobile}) step=${cust.verificationStep}`);
const expertAdmin = await prisma.adminUser.create({
data: {
fullName: 'کارشناس مدارک', firstName: 'کارشناس', lastName: 'مدارک',
mobile: `0918${uniq.slice(0, 3)}22`,
passwordHash: '!NO_PASSWORD_SET_YET', role: 'SALES_EXPERT', isActive: true, wagePercent: '3',
},
});
await prisma.salesExpertProfile.create({
data: { adminUserId: expertAdmin.id, gramLevel: '120' },
});
made.admins.push(expertAdmin.id);
// ── 5. NO TOKEN ─────────────────────────────────────────────────────────
step('5 — both routes require a session');
const anonComplete = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: 'x', shop_image_id: 'x', ncode_image_id: 'x',
});
chk(anonComplete.status === 401, `complete-profile with no token -> ${anonComplete.status} (expected 401)`);
const anonUpload = await upload(null, 'business-license');
chk(anonUpload.status === 401, `documents with no token -> ${anonUpload.status} (expected 401)`);
// ── SIGN IN ─────────────────────────────────────────────────────────────
step('SIGN IN — the customer, through the ordinary app flow');
await call('POST', 'customer/auth/request-otp', { username: cust.mobile });
const login = await signIn(cust.mobile);
chk(login.ok, `verify-otp -> ${login.status}`);
const token = login.body?.token;
chk(Boolean(token), 'an app token came back');
// The page opens against /customer/me, which is what the router guard reads.
const me = await call('GET', 'customer/me', undefined, token);
chk(me.ok, `GET customer/me -> ${me.status}`);
chk(me.body?.verificationStep === 'UNVERIFIED',
`the page is reachable: verificationStep is ${me.body?.verificationStep}`);
// ── 2. VALIDATION — the empty state ─────────────────────────────────────
step('2 — EMPTY STATE: each document is required');
const ids = { business_license_image_id: 'a', shop_image_id: 'b', ncode_image_id: 'c' };
for (const key of Object.keys(ids)) {
const partial = { ...ids };
delete partial[key];
const r = await call('POST', 'customer/me/complete-profile', partial, token);
chk(r.status === 400 || r.status === 422, `missing ${key} -> ${r.status} (expected 400/422)`);
}
const allEmpty = await call('POST', 'customer/me/complete-profile', {}, token);
chk(allEmpty.status === 400 || allEmpty.status === 422,
`an empty body -> ${allEmpty.status} (expected 400/422)`);
const stillUnverified = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(stillUnverified.verificationStep === 'UNVERIFIED',
'a rejected submission left the verification step untouched');
// ── 3. ERROR STATE — an id that names no file ───────────────────────────
step('3 — ERROR STATE: an unknown document id is refused');
const bogus = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: 'no-such-file-id',
shop_image_id: 'no-such-file-id',
ncode_image_id: 'no-such-file-id',
}, token);
chk(bogus.status === 400, `unknown ids -> ${bogus.status} (expected 400)`);
const afterBogus = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(afterBogus.verificationStep === 'UNVERIFIED',
'and nothing was written: still UNVERIFIED');
chk(afterBogus.businessLicenseUrl === null,
'and no document URL was stored');
// ── 1. MAIN FLOW ────────────────────────────────────────────────────────
step('1 — MAIN FLOW: upload three documents, then complete');
const licence = await upload(token, 'business-license');
chk(licence.ok, `upload the business licence -> ${licence.status}`);
const shop = await upload(token, 'shop-image');
chk(shop.ok, `upload the shop front -> ${shop.status}`);
const ncode = await upload(token, 'national-card');
chk(ncode.ok, `upload the national card -> ${ncode.status}`);
for (const u of [licence, shop, ncode]) if (u.body?.id) made.files.push(u.body.id);
chk(Boolean(licence.body?.id && licence.body?.url),
'each upload answers with an id and a stored url');
chk(licence.body?.uploadedByAdminUserId === null,
'a customer upload is not attributed to an admin user');
const done = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: licence.body.id,
shop_image_id: shop.body.id,
ncode_image_id: ncode.body.id,
}, token);
chk(done.ok, `complete-profile -> ${done.status}`);
const after = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(after.verificationStep === 'VERIFIED',
`the step moved to the reference's live value (VERIFIED), got ${after.verificationStep}`);
chk(after.businessLicenseUrl === licence.body.url, 'the business licence url was stored');
chk(after.shopImageUrl === shop.body.url, 'the shop front url was stored');
chk(after.nationalIdImageUrl === ncode.body.url, 'the national card url was stored');
// The router guard must now keep the page shut.
const meAfter = await call('GET', 'customer/me', undefined, token);
chk(meAfter.body?.verificationStep === 'VERIFIED',
'the app now reads VERIFIED, so the guard redirects the page home');
// ── 7. SEC-14 — extra fields are ignored, not mass-assigned ─────────────
step('7 — SEC-14: extra body fields are IGNORED, not written to the row');
const before = await prisma.customer.findUnique({ where: { id: cust.id } });
const inject = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: licence.body.id,
shop_image_id: shop.body.id,
ncode_image_id: ncode.body.id,
// Every one of these was writable through the reference's $request->all().
goldCreditLimitGrams: '999999',
gram_level: '999999',
goldDebtGrams: '-500',
makingChargeOffsetPercent: '0',
isActive: false,
mobile: '09000000000',
}, token);
chk(inject.status === 400 || inject.ok,
`a body with extra fields -> ${inject.status}`);
const poked = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(String(poked.goldCreditLimitGrams) === String(before.goldCreditLimitGrams),
`the credit limit is unchanged (${poked.goldCreditLimitGrams})`);
chk(String(poked.goldDebtGrams) === String(before.goldDebtGrams),
`the gold debt is unchanged (${poked.goldDebtGrams})`);
chk(String(poked.makingChargeOffsetPercent) === String(before.makingChargeOffsetPercent),
`the making charge is unchanged (${poked.makingChargeOffsetPercent})`);
chk(poked.isActive === before.isActive, 'isActive is unchanged');
chk(poked.mobile === before.mobile, `the mobile is unchanged (${poked.mobile})`);
// ── 4. THE SUBJECT COMES FROM THE TOKEN ─────────────────────────────────
step('4 — the customer comes from the TOKEN, never from the body');
const victim = await mkCustomer('44', 'مشتری دیگر');
const victimBefore = await prisma.customer.findUnique({ where: { id: victim.id } });
chk(victimBefore.verificationStep === 'UNVERIFIED', 'the second customer starts UNVERIFIED');
await call('POST', 'customer/me/complete-profile', {
business_license_image_id: licence.body.id,
shop_image_id: shop.body.id,
ncode_image_id: ncode.body.id,
// Both spellings, in case either were read.
customerId: victim.id,
customer_id: victim.id,
id: victim.id,
}, token);
const victimAfter = await prisma.customer.findUnique({ where: { id: victim.id } });
chk(victimAfter.verificationStep === 'UNVERIFIED',
"naming another customer in the body did NOT touch their row");
chk(victimAfter.businessLicenseUrl === null,
'and stored no document against them');
// ── 6. AN EXPERT CAN COMPLETE THEIR OWN PROFILE ─────────────────────────
step('6 — D21: the same page works for a sales expert');
await call('POST', 'customer/auth/request-otp', { username: expertAdmin.mobile });
const expLogin = await signIn(expertAdmin.mobile);
chk(expLogin.ok, `the expert signs in -> ${expLogin.status}`);
const expToken = expLogin.body?.token;
const prof = await prisma.salesExpertProfile.findUnique({ where: { adminUserId: expertAdmin.id } });
if (prof?.tradingCustomerId) made.customers.push(prof.tradingCustomerId);
const expUpload = await upload(expToken, 'business-license');
chk(expUpload.ok, `the expert uploads a document -> ${expUpload.status}`);
if (expUpload.body?.id) made.files.push(expUpload.body.id);
const expDone = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: expUpload.body.id,
shop_image_id: expUpload.body.id,
ncode_image_id: expUpload.body.id,
}, expToken);
chk(expDone.ok, `the expert completes their profile -> ${expDone.status}`);
const expTrading = prof?.tradingCustomerId
? await prisma.customer.findUnique({ where: { id: prof.tradingCustomerId } })
: null;
chk(expTrading?.verificationStep === 'VERIFIED',
'it landed on the expert\'s own trading account, not on anyone else');
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
await prisma.uploadedFile.deleteMany({ where: { id: { in: made.files } } });
await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } });
await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: made.admins } } });
await prisma.customer.deleteMany({ where: { id: { in: made.customers } } });
await prisma.adminUser.deleteMany({ where: { id: { in: made.admins } } });
console.log(
` deleted ${made.files.length} uploaded-file row(s), ${made.customers.length} customer(s) ` +
`(including the expert trading account this run created) and ${made.admins.length} admin user(s). ` +
'All were created by this run; no pre-existing data was touched. ' +
'The uploaded blobs themselves are left on disk, as every other test does.',
);
console.log(`\n${'='.repeat(60)}`);
console.log(`COMPLETE PROFILE: passed ${pass} failed ${fail}`);
if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); }
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});
+226
View File
@@ -0,0 +1,226 @@
/**
* The whole delivery flow, against the DOCKER COMPOSE stack.
*
* What the owner asked to see proven inside compose:
* 1. a sales expert signs into the app with the master code
* 2. an ordinary customer signs into the app with the master code
* 3. BOTH place an order
* 4. an ADMIN sees both of those orders in the admin panel
*
* Unlike the other scripts this one talks ONLY over HTTP — it never opens a
* Prisma client — because the point is to exercise the deployed container as a
* black box, including its own boot-time migration run. Setup and teardown go
* through the admin API, the same way an operator would.
*
* Usage: node scripts/test-compose-e2e.cjs [apiBaseUrl] [masterCode]
* e.g. node scripts/test-compose-e2e.cjs http://localhost:3100/api 1379
*/
const API = (process.argv[2] || 'http://localhost:3100/api').replace(/\/$/, '');
const MASTER = process.argv[3] || '1379';
const ADMIN_USER = process.argv[4] || '09128081392';
const ADMIN_PASS = process.argv[5] || '09128081392';
let pass = 0, fail = 0;
const failures = [];
const chk = (c, m) => {
if (c) { pass++; console.log(' PASS ' + m); }
else { fail++; failures.push(m); console.log(' FAIL ' + m); }
};
const step = (t) => console.log('\n' + t);
const call = async (method, p, body, token) => {
const res = await fetch(`${API}/${p}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let payload = null;
try { payload = JSON.parse(text); } catch { /* empty */ }
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
const signIn = (mobile) =>
call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER });
const uniq = String(Date.now()).slice(-7);
const made = { customers: [], experts: [], orders: [] };
(async () => {
// ── ADMIN ───────────────────────────────────────────────────────────────
step('0 — ADMIN signs into the panel');
const adminLogin = await call('POST', 'admin/auth/login', {
phone: ADMIN_USER,
password: ADMIN_PASS,
});
chk(adminLogin.ok, `POST admin/auth/login -> ${adminLogin.status}`);
const adminToken = adminLogin.body?.api_token;
if (!adminToken) {
console.log('\n Could not sign in as admin; pass the credentials as argv[4]/argv[5].');
console.log(' Response: ' + adminLogin.raw.slice(0, 300));
process.exit(1);
}
chk(Boolean(adminToken), 'an admin token came back');
// ── SETUP through the ADMIN API, as an operator would ───────────────────
step('SETUP — create a sales expert and a customer through the admin panel');
const expertMobile = `0913${uniq}`;
const expert = await call('POST', 'admin/users', {
fname: 'کارشناس', lname: 'کامپوز',
mobile: expertMobile, password: 'Test12345',
role: 'SALES_EXPERT', percent: 3.5, gram_level: 150,
}, adminToken);
chk(expert.ok, `create the sales expert -> ${expert.status}` + (expert.ok ? '' : ' ' + expert.raw.slice(0, 160)));
if (expert.body?.id) made.experts.push(expert.body.id);
const custMobile = `0914${uniq}`;
const cust = await call('POST', 'admin/customers-omde/create', {
fname: 'مشتری', lname: 'کامپوز', name: 'مشتری کامپوز',
mobile: custMobile, percent: 3, type: 2,
store_name: 'گالری کامپوز', gram_level: 200,
}, adminToken);
chk(cust.ok, `create the customer -> ${cust.status}` + (cust.ok ? '' : ' ' + cust.raw.slice(0, 160)));
const custId = cust.body?.id;
if (custId) made.customers.push(custId);
// A product to order.
step('SETUP — find an orderable product variant');
const products = await call('GET', 'catalog/products');
chk(products.ok, `GET catalog/products -> ${products.status}`);
const variant = (products.body ?? [])
.flatMap((p) => p.variants ?? [])
.find((v) => v && v.id);
chk(Boolean(variant), `found a variant to order (${variant?.id ?? 'none'})`);
if (!variant) {
console.log(' No product variants in this database — cannot place orders.');
process.exit(1);
}
// ── 1. THE EXPERT SIGNS IN ──────────────────────────────────────────────
step('1 — THE SALES EXPERT signs into the app with the master code');
const expReq = await call('POST', 'customer/auth/request-otp', { username: expertMobile });
chk(expReq.ok, `request-otp -> ${expReq.status}`);
const expLogin = await signIn(expertMobile);
chk(expLogin.ok, `verify-otp with the master code -> ${expLogin.status}`);
const expToken = expLogin.body?.token;
chk(Boolean(expToken), 'an app token came back');
const whoami = await call('GET', 'expert/whoami', undefined, expToken);
chk(whoami.body?.isSalesExpert === true,
`the app is told this session is an expert (${whoami.body?.isSalesExpert})`);
// ── 2. THE CUSTOMER SIGNS IN ────────────────────────────────────────────
step('2 — AN ORDINARY CUSTOMER signs into the app with the master code');
await call('POST', 'customer/auth/request-otp', { username: custMobile });
const custLogin = await signIn(custMobile);
chk(custLogin.ok, `verify-otp with the master code -> ${custLogin.status}`);
const custToken = custLogin.body?.token;
chk(Boolean(custToken), 'an app token came back');
const custWho = await call('GET', 'expert/whoami', undefined, custToken);
chk(custWho.body?.isSalesExpert === false,
`and is NOT flagged as an expert (${custWho.body?.isSalesExpert})`);
// ── 3. BOTH PLACE AN ORDER ──────────────────────────────────────────────
step('3 — BOTH place an order from the app');
// The exact payload both apps send from the cart (CartView.vue:200-211):
// wagePercent comes from the variant the cart pinned. No customerId — the
// server takes the subject from the token (D21).
const placeOrder = async (token, who) => {
const r = await call('POST', 'orders', {
items: [
{
productVariantId: variant.id,
quantity: 1,
wagePercent: parseFloat(variant.defaultWagePercent ?? '0'),
},
],
}, token);
chk(r.ok, `${who} places an order -> ${r.status}${r.ok ? ' (' + r.body?.orderNumber + ')' : ' ' + r.raw.slice(0, 160)}`);
if (r.body?.id) made.orders.push({ id: r.body.id, number: r.body.orderNumber, who });
return r.body;
};
const expOrder = await placeOrder(expToken, 'the EXPERT');
const custOrder = await placeOrder(custToken, 'the CUSTOMER');
chk(Boolean(expOrder?.orderNumber && custOrder?.orderNumber),
'both orders came back with an order number');
chk(expOrder?.orderNumber !== custOrder?.orderNumber,
'and they are two distinct orders');
// Each sees their own order in their own history, and only their own.
const expHistory = await call('GET', 'customer/me/orders', undefined, expToken);
chk(expHistory.body?.some((o) => o.id === expOrder?.id),
"the expert's own order is in the expert's history");
chk(!expHistory.body?.some((o) => o.id === custOrder?.id),
"and the customer's order is NOT");
const custHistory = await call('GET', 'customer/me/orders', undefined, custToken);
chk(custHistory.body?.some((o) => o.id === custOrder?.id),
"the customer's own order is in the customer's history");
chk(!custHistory.body?.some((o) => o.id === expOrder?.id),
"and the expert's order is NOT");
// ── 4. THE ADMIN SEES BOTH ──────────────────────────────────────────────
step('4 — THE ADMIN sees both orders in the panel');
const list = await call('POST', 'admin/orders/list', { page: 1, limit: 100 }, adminToken);
chk(list.ok, `POST admin/orders/list -> ${list.status}`);
const rows = list.body?.rows ?? list.body?.data ?? list.body?.result ?? [];
chk(Array.isArray(rows) && rows.length > 0, `the grid returned ${rows.length} row(s)`);
const findRow = (id, number) =>
rows.find((r) => r.id === id || r.orderNumber === number);
chk(Boolean(findRow(expOrder?.id, expOrder?.orderNumber)),
`the EXPERT's order ${expOrder?.orderNumber} appears in the admin grid`);
chk(Boolean(findRow(custOrder?.id, custOrder?.orderNumber)),
`the CUSTOMER's order ${custOrder?.orderNumber} appears in the admin grid`);
// And the admin can open each one.
for (const o of made.orders) {
const detail = await call('GET', `orders/admin/${o.id}`, undefined, adminToken);
chk(detail.ok, `the admin can open ${o.who}'s order ${o.number} -> ${detail.status}`);
chk(detail.body?.items?.length > 0, `and it carries its items (${detail.body?.items?.length})`);
}
// ── 5. TOKEN BOUNDARIES still hold inside compose ───────────────────────
step('5 — the token boundaries hold in the deployed container too');
const appOnAdmin = await call('POST', 'admin/orders/list', { page: 1 }, expToken);
chk(appOnAdmin.status === 401 || appOnAdmin.status === 403,
`an expert's APP token on an admin route -> ${appOnAdmin.status}`);
const custOnExpert = await call('POST', 'expert/my-customers', { search: null }, custToken);
chk(custOnExpert.status === 403 || custOnExpert.status === 401,
`a customer's token on an expert route -> ${custOnExpert.status}`);
const adminOnApp = await call('GET', 'customer/me', undefined, adminToken);
chk(adminOnApp.status === 401, `an admin token on an app route -> ${adminOnApp.status}`);
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
console.log(' This run created, through the admin API:');
for (const o of made.orders) console.log(` order ${o.number} (${o.who})`);
console.log(` customer ${custMobile}, sales expert ${expertMobile}`);
console.log(' They are LEFT IN PLACE deliberately: this is the compose stack, its');
console.log(' database is separate from development, and the rows are what the');
console.log(' owner will look at in the panel. Delete them from the admin panel');
console.log(' after the demo if they are not wanted.');
console.log(`\n${'='.repeat(60)}`);
console.log(`COMPOSE END-TO-END: passed ${pass} failed ${fail}`);
if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); }
console.log('='.repeat(60));
process.exit(fail ? 1 : 0);
})().catch((e) => {
console.error('\nABORTED:', e);
process.exit(1);
});
@@ -0,0 +1,76 @@
/**
* The other half of the D20 contract, run against an API started WITHOUT
* OTP_MASTER_CODE:
* * the master code must now be rejected;
* * the real, freshly-issued code must still work.
*
* This is the negative case RULE E1 demands: without it, "the master code
* works" proves nothing about whether it can be switched off.
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = 'http://localhost:3000/api';
let pass = 0, fail = 0;
const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); };
const MASTER = '778899';
const verify = async (mobile, code) => {
const r = await fetch(API + '/customer/auth/verify-otp', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: mobile, password: code }),
});
return { status: r.status, body: await r.json().catch(() => ({})) };
};
(async () => {
const c = await prisma.customer.findFirst({
where: { isActive: true, isArchived: false }, select: { id: true, mobile: true, fullName: true },
});
console.log(`Testing against customer ${c.fullName} (${c.mobile})\n`);
console.log('CASE 3 — NEGATIVE: with the env var absent, the master code is REJECTED');
const m = await verify(c.mobile, MASTER);
chk(m.status === 401, `HTTP ${m.status} (expected 401)`);
chk(!m.body.token, 'no token issued');
console.log('\nCASE 4 — the REAL code still works');
// Clear the throttle from earlier runs so requestOtp is allowed.
const removed = await prisma.customerOtp.deleteMany({ where: { mobile: c.mobile } });
console.log(` (cleared ${removed.count} of this test number's own OTP rows to bypass the resend cooldown)`);
const req = await fetch(API + '/customer/auth/request-otp', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: c.mobile }),
});
chk(req.status === 200 || req.status === 201, `request-otp -> HTTP ${req.status}`);
// The code is hashed in the DB, so read it from the server log line that
// dev mode prints instead of trying to reverse the hash.
const fs = require('fs');
const log = fs.readFileSync(process.argv[2], 'utf8');
const codeLine = log
.split('\n')
.reverse()
.find((l) => l.includes('OTP for ' + c.mobile));
const real = codeLine ? (codeLine.match(/:\s*(\d{4})/) || [])[1] : null;
if (!real) {
console.log(' FAIL could not read the dev OTP from the log; last lines:');
console.log(log.split('\n').slice(-5).join('\n'));
fail++;
} else {
console.log(` (dev log exposed the code: ${real})`);
const v = await verify(c.mobile, real);
chk(v.status === 200 || v.status === 201, `verify with the REAL code -> HTTP ${v.status}`);
chk(typeof v.body.token === 'string', 'a JWT came back');
console.log('\n and it is single-use: replaying the same code fails');
const again = await verify(c.mobile, real);
chk(again.status === 401, `replay -> HTTP ${again.status} (expected 401)`);
}
console.log(`\npassed: ${pass} failed: ${fail}`);
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });
+63
View File
@@ -0,0 +1,63 @@
/**
* Live end-to-end check of the D20 master OTP against a running API.
* All Persian text is a literal in this file, never a shell argument (D15).
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = 'http://localhost:3000/api';
let pass = 0, fail = 0;
const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); };
const MASTER = '778899';
(async () => {
const c = await prisma.customer.findFirst({
where: { isActive: true, isArchived: false }, select: { id: true, mobile: true, fullName: true },
});
if (!c) { console.log('no active customer to test with'); process.exit(1); }
console.log(`Testing against customer ${c.fullName} (${c.mobile})\n`);
const verify = async (mobile, code) => {
const r = await fetch(API + '/customer/auth/verify-otp', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: mobile, password: code }),
});
return { status: r.status, body: await r.json().catch(() => ({})) };
};
console.log('CASE 1 — the master code signs an active customer in');
const a = await verify(c.mobile, MASTER);
chk(a.status === 200 || a.status === 201, `HTTP ${a.status}`);
chk(typeof a.body.token === 'string' && a.body.token.length > 20, 'a JWT came back');
chk(a.body.customer?.mobile === c.mobile, 'it is THIS customer, not another');
console.log('\n the token actually works on a protected customer route');
const me = await fetch(API + '/customer/me', { headers: { Authorization: 'Bearer ' + a.body.token } });
chk(me.status === 200, `GET customer/me -> ${me.status}`);
console.log('\nCASE 1b — Persian digits for the same master code');
const p = await verify(c.mobile, '۷۷۸۸۹۹');
chk(p.status === 200 || p.status === 201, `HTTP ${p.status} — faToEn applied to the code`);
console.log('\nCASE 2 — NEGATIVE: a near-miss code is still rejected');
const bad = await verify(c.mobile, '778898');
chk(bad.status === 401, `HTTP ${bad.status} (expected 401)`);
chk(!bad.body.token, 'no token issued');
console.log('\nCASE 2b — NEGATIVE: an unknown mobile is rejected even with the master code');
const unknown = await verify('09999999999', MASTER);
chk(unknown.status === 401, `HTTP ${unknown.status} (expected 401)`);
console.log('\nCASE 2c — NEGATIVE: an INACTIVE customer is rejected even with the master code');
const inactive = await prisma.customer.findFirst({ where: { isActive: false }, select: { mobile: true } });
if (inactive) {
const r = await verify(inactive.mobile, MASTER);
chk(r.status === 401, `HTTP ${r.status} (expected 401)`);
} else {
console.log(' (skipped — no inactive customer in the DB)');
}
console.log(`\npassed: ${pass} failed: ${fail}`);
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });
+109
View File
@@ -0,0 +1,109 @@
/**
* Integration test for the `customer_sync_full_name` database trigger
* (migration 20260922120000_customer_name_parts).
*
* The trigger is SQL, not Prisma, so jest's unit suite cannot cover it — it
* needs a live database. Run it against a development DB:
*
* node scripts/test-name-trigger.cjs
*
* Exits non-zero on any failure. It creates ONE temporary Customer row and
* deletes it again; the deletion is reported on stdout (CHANGELOG rule 2).
*
* The three directions required by the owner's ruling of 2026-09-22:
* parts only -> fullName is rebuilt
* fullName only -> the parts are re-derived
* both at once -> THE PARTS WIN
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
let failures = 0;
function check(condition, message) {
if (!condition) failures++;
console.log(`${condition ? ' PASS' : ' FAIL'} ${message}`);
}
const read = async (id) =>
(
await prisma.$queryRawUnsafe(
`SELECT "fullName","firstName","lastName","makingChargeOffsetPercent" AS pct
FROM "Customer" WHERE id=$1`,
id,
)
)[0];
(async () => {
const id = 'trigger-test-' + Date.now();
const unique = Date.now();
await prisma.$executeRawUnsafe(
`INSERT INTO "Customer"
(id,"fullName","firstName","lastName",mobile,"nationalCode","passwordHash","updatedAt")
VALUES ($1,'',$2,$3,$4,$5,'x',NOW())`,
id,
'محمد علی',
'احمدی',
'09' + String(unique).slice(-9),
'NC' + unique,
);
// The owner's worked example: a two-token FIRST name must survive intact.
console.log('\nCASE 0 — INSERT with parts only («محمد علی» + «احمدی»)');
let r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.fullName === 'محمد علی احمدی', 'fullName rebuilt from the parts');
check(r.firstName === 'محمد علی', 'a two-token first name is NOT re-split');
check(r.lastName === 'احمدی', 'last name kept separate');
console.log('\nCASE 1 — UPDATE the parts only');
await prisma.$executeRawUnsafe(
`UPDATE "Customer" SET "firstName"='رضا',"lastName"='کریمی' WHERE id=$1`,
id,
);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.fullName === 'رضا کریمی', 'fullName rebuilt from the parts');
console.log('\nCASE 2 — UPDATE fullName only');
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سعید موسوی' WHERE id=$1`, id);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.firstName === 'سعید' && r.lastName === 'موسوی', 'parts re-derived from fullName');
console.log('\nCASE 3 — UPDATE BOTH in one statement (the parts must win)');
await prisma.$executeRawUnsafe(
`UPDATE "Customer" SET "fullName"='IGNORED',"firstName"='نرگس',"lastName"='رحیمی' WHERE id=$1`,
id,
);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.fullName === 'نرگس رحیمی', 'parts win; the submitted fullName is discarded');
console.log('\nCASE 4 — a multi-token fullName splits first-token / rest');
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سید محمد حسینی' WHERE id=$1`, id);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.firstName === 'سید' && r.lastName === 'محمد حسینی', 'first token / the rest');
console.log('\nCASE 5 — an unrelated update leaves the name untouched');
const before = await read(id);
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET mobile=mobile WHERE id=$1`, id);
const after = await read(id);
check(JSON.stringify(before) === JSON.stringify(after), 'no-op update changes nothing');
console.log('\nCASE 6 — makingChargeOffsetPercent');
check(Number(after.pct) === 0, 'defaults to 0, so no existing price moves');
await prisma.$executeRawUnsafe(`DELETE FROM "Customer" WHERE id=$1`, id);
console.log(`\nCleanup: deleted 1 temporary Customer row (${id}).`);
console.log(failures === 0 ? '\nALL PASSED' : `\n${failures} FAILED`);
await prisma.$disconnect();
process.exit(failures ? 1 : 0);
})().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+275
View File
@@ -0,0 +1,275 @@
/**
* "گزارش" — the account statement (D24).
*
* The reference proxied an external accounting system
* (henza_server Customer/TahesabController). The owner chose option الف: build
* the same UI and flow over the target's OWN ledger, with no external call.
*
* Covers what the owner asked for per page — opening, the empty state, the
* error state and the main flow — plus what makes a statement trustworthy:
*
* 1. empty state: a customer with no entries gets an empty result, not a 500
* 2. main flow: entries appear with the right in/out columns
* 3. the RUNNING BALANCE is correct, and its last value agrees with the
* cached Customer.goldDebtGrams / fiatDebtRial the rest of the app shows
* 4. UNCONFIRMED entries are excluded, exactly as they are from the cached
* balance
* 5. the detail list and the single-document read
* 6. SEC-16: another customer's document id returns NOTHING. The reference's
* detail() took the factor code from the URL with no ownership check at
* all, so any signed-in customer could read anyone's document.
* 7. no token is a 401 on every route
* 8. balances endpoint agrees with the customer row
*
* Every Persian string is a literal here and travels as UTF-8 JSON (D15).
*
* Requires the API running WITH OTP_MASTER_CODE set.
* Usage: node scripts/test-report.cjs [apiBaseUrl] [masterCode]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
const MASTER = process.argv[3] || '1379';
let pass = 0, fail = 0;
const failures = [];
const chk = (c, m) => {
if (c) { pass++; console.log(' PASS ' + m); }
else { fail++; failures.push(m); console.log(' FAIL ' + m); }
};
const step = (t) => console.log('\n' + t);
const call = async (method, p, body, token) => {
const res = await fetch(`${API}/${p}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let payload = null;
try { payload = JSON.parse(text); } catch { /* empty */ }
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
const signIn = (mobile) =>
call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER });
const made = { customers: [], entries: [] };
const uniq = String(Date.now()).slice(-7);
(async () => {
step('SETUP — two customers; one with a ledger, one with none');
const mkCustomer = async (suffix, name) => {
const c = await prisma.customer.create({
data: {
fullName: name,
firstName: name.split(' ')[0],
lastName: name.split(' ').slice(1).join(' '),
mobile: `0919${uniq.slice(0, 3)}${suffix}`,
nationalCode: `nc${uniq}${suffix}`,
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
goldCreditLimitGrams: '500',
},
});
made.customers.push(c.id);
return c;
};
const owner = await mkCustomer('11', 'مشتری گزارش');
const other = await mkCustomer('22', 'مشتری دیگر');
const empty = await mkCustomer('33', 'مشتری بدون سند');
console.log(` owner: ${owner.fullName} · other: ${other.fullName} · empty: ${empty.fullName}`);
// ── 7. NO TOKEN ─────────────────────────────────────────────────────────
step('7 — every route requires a session');
for (const path of ['customer/me/report', 'customer/me/report/detail', 'customer/me/report/balances']) {
const r = await call('GET', path);
chk(r.status === 401, `GET ${path} with no token -> ${r.status} (expected 401)`);
}
// ── 1. EMPTY STATE ──────────────────────────────────────────────────────
step('1 — EMPTY STATE: a customer with no ledger entries');
await call('POST', 'customer/auth/request-otp', { username: empty.mobile });
const emptyLogin = await signIn(empty.mobile);
chk(emptyLogin.ok, `the empty customer signs in -> ${emptyLogin.status}`);
const emptyToken = emptyLogin.body?.token;
const emptyReport = await call('GET', 'customer/me/report', undefined, emptyToken);
chk(emptyReport.ok, `GET report -> ${emptyReport.status}`);
chk(Array.isArray(emptyReport.body?.result) && emptyReport.body.result.length === 0,
'the statement is an empty list, not an error');
chk(emptyReport.body?.count === 0, `count is 0 (got ${emptyReport.body?.count})`);
const emptyDetail = await call('GET', 'customer/me/report/detail', undefined, emptyToken);
chk(emptyDetail.ok && emptyDetail.body?.result?.length === 0,
`the detail tab is empty too -> ${emptyDetail.status}`);
// ── BUILD A LEDGER ──────────────────────────────────────────────────────
step('SETUP — a ledger for the owner: gold out, gold in, rial in, plus one UNCONFIRMED');
const mkEntry = async (data) => {
const e = await prisma.ledgerEntry.create({ data: { customerId: owner.id, ...data } });
made.entries.push(e.id);
return e;
};
// +12.5 gold owed
await mkEntry({
currency: 'GOLD_GRAM', direction: 'DEBIT', sourceType: 'ORDER_STOCK',
amount: '12.5', confirmed: true, confirmedAt: new Date(),
note: 'سفارش اول',
});
// -4.25 gold (a melted-gold repayment, with the document metadata)
await mkEntry({
currency: 'GOLD_GRAM', direction: 'CREDIT', sourceType: 'REPAYMENT_MELTED_GOLD',
amount: '4.25', confirmed: true, confirmedAt: new Date(),
karatPerMille: 750, originalValue: '4.3', labName: 'آزمایشگاه مرکزی', hallmark: 'A-1001',
note: 'طلای آبشده',
});
// +1,000,000 rial owed
await mkEntry({
currency: 'RIAL', direction: 'DEBIT', sourceType: 'ADJUSTMENT',
amount: '1000000', confirmed: true, confirmedAt: new Date(),
note: 'اصلاحیه ریالی',
});
// An UNCONFIRMED entry that must NOT appear anywhere.
const pending = await mkEntry({
currency: 'GOLD_GRAM', direction: 'DEBIT', sourceType: 'ORDER_STOCK',
amount: '99.999', confirmed: false,
note: 'سند تاییدنشده',
});
// The cached balances the rest of the app shows.
await prisma.customer.update({
where: { id: owner.id },
data: { goldDebtGrams: '8.25', fiatDebtRial: '1000000' },
});
// One document for the OTHER customer, for the cross-read test.
const foreign = await prisma.ledgerEntry.create({
data: {
customerId: other.id, currency: 'GOLD_GRAM', direction: 'DEBIT',
sourceType: 'ORDER_STOCK', amount: '7', confirmed: true, confirmedAt: new Date(),
note: 'سند مشتری دیگر',
},
});
made.entries.push(foreign.id);
// ── 2/3/4. MAIN FLOW ────────────────────────────────────────────────────
step('2 — MAIN FLOW: the statement');
await call('POST', 'customer/auth/request-otp', { username: owner.mobile });
const login = await signIn(owner.mobile);
chk(login.ok, `the owner signs in -> ${login.status}`);
const token = login.body?.token;
const rep = await call('GET', 'customer/me/report', undefined, token);
chk(rep.ok, `GET report -> ${rep.status}`);
const rows = rep.body?.result ?? [];
chk(rows.length === 3, `three CONFIRMED entries came back (got ${rows.length})`);
// Newest first, as the reference displayed it.
chk(rows[0]?.note === 'اصلاحیه ریالی', `newest first: top row is "${rows[0]?.note}"`);
const gold = rows.find((r) => r.note === 'سفارش اول');
chk(gold?.geram_kh === '12.500', `a gold DEBIT lands in geram_kh (${gold?.geram_kh})`);
chk(gold?.geram_v === '0.000', 'and not in geram_v');
const back = rows.find((r) => r.note === 'طلای آبشده');
chk(back?.geram_v === '4.250', `a gold CREDIT lands in geram_v (${back?.geram_v})`);
const rialRow = rows.find((r) => r.note === 'اصلاحیه ریالی');
chk(rialRow?.rial_kh === '1000000.00', `a rial DEBIT lands in rial_kh (${rialRow?.rial_kh})`);
step('4 — UNCONFIRMED entries are excluded');
chk(!rows.some((r) => r.note === 'سند تاییدنشده'),
'the unconfirmed 99.999g entry does NOT appear in the statement');
step('3 — the RUNNING BALANCE');
// Oldest first: +12.5, then -4.25 => 8.25. Rows come back newest-first.
chk(gold?.tahesab_T === '12.500', `after the first entry the gold balance is 12.500 (got ${gold?.tahesab_T})`);
chk(back?.tahesab_T === '8.250', `after the repayment it is 8.250 (got ${back?.tahesab_T})`);
const cached = await prisma.customer.findUnique({ where: { id: owner.id } });
chk(String(Number(back.tahesab_T)) === String(Number(cached.goldDebtGrams)),
`and it agrees with the cached Customer.goldDebtGrams (${cached.goldDebtGrams})`);
chk(rialRow?.tahesab_P === '1000000.00',
`the rial balance is 1000000.00 (got ${rialRow?.tahesab_P})`);
chk(String(Number(rialRow.tahesab_P)) === String(Number(cached.fiatDebtRial)),
`and it agrees with the cached Customer.fiatDebtRial (${cached.fiatDebtRial})`);
step('8 — the balances endpoint agrees with the customer row');
const bal = await call('GET', 'customer/me/report/balances', undefined, token);
chk(bal.ok, `GET balances -> ${bal.status}`);
const goldBal = bal.body?.result?.find((r) => r.arz === 'gold');
const rialBal = bal.body?.result?.find((r) => r.arz === 'IRR');
chk(goldBal?.mandeh === '8.250', `gold balance ${goldBal?.mandeh}`);
chk(rialBal?.mandeh === '1000000.00', `rial balance ${rialBal?.mandeh}`);
// ── 5. DETAIL ───────────────────────────────────────────────────────────
step('5 — the detail tab and one document');
const det = await call('GET', 'customer/me/report/detail', undefined, token);
chk(det.ok, `GET detail -> ${det.status}`);
chk(det.body?.result?.length === 3, `three documents (got ${det.body?.result?.length})`);
chk(!det.body?.result?.some((d) => d.note === 'سند تاییدنشده'),
'the unconfirmed entry is excluded here too');
const melted = det.body?.result?.find((d) => d.note === 'طلای آبشده');
chk(melted?.ayar === 750, `the melted-gold document carries its عیار (${melted?.ayar})`);
chk(melted?.name_az === 'آزمایشگاه مرکزی', `and its lab name (${melted?.name_az})`);
chk(melted?.sh_sharti === 'A-1001', `and its hallmark (${melted?.sh_sharti})`);
chk(melted?.vazn === '4.300',
`وزن خالص is the PRE-normalization weight, as a paper document states (${melted?.vazn})`);
chk(melted?.nO_kar === null && melted?.jens_felez === null,
'fields the target cannot know are null, never invented (REP-01)');
const one = await call('GET', `customer/me/report/${back.factor_Code}`, undefined, token);
chk(one.ok, `GET one document -> ${one.status}`);
chk(one.body?.result?.length === 1, `exactly one row came back (got ${one.body?.result?.length})`);
chk(one.body?.result?.[0]?.factor_Code === back.factor_Code, 'and it is the one asked for');
// ── 6. SEC-16 ───────────────────────────────────────────────────────────
step("6 — SEC-16: another customer's document is NOT readable");
const stolen = await call('GET', `customer/me/report/${foreign.id}`, undefined, token);
chk(stolen.ok, `the request itself succeeds -> ${stolen.status}`);
chk(stolen.body?.result?.length === 0,
"but it returns NOTHING — the id is only a filter on top of the token's scope");
// And the reverse direction, so the scope is not accidentally one-way.
await call('POST', 'customer/auth/request-otp', { username: other.mobile });
const otherLogin = await signIn(other.mobile);
const otherToken = otherLogin.body?.token;
const reverse = await call('GET', `customer/me/report/${back.factor_Code}`, undefined, otherToken);
chk(reverse.body?.result?.length === 0,
"and the other customer cannot read the owner's document either");
const otherRep = await call('GET', 'customer/me/report', undefined, otherToken);
chk(otherRep.body?.result?.length === 1,
`the other customer sees only their OWN one entry (got ${otherRep.body?.result?.length})`);
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
await prisma.ledgerEntry.deleteMany({ where: { id: { in: made.entries } } });
await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } });
await prisma.customer.deleteMany({ where: { id: { in: made.customers } } });
console.log(
` deleted ${made.entries.length} ledger entr(ies) and ${made.customers.length} customer(s). ` +
'All were created by this run; no pre-existing data was touched.',
);
void pending;
console.log(`\n${'='.repeat(60)}`);
console.log(`REPORT: passed ${pass} failed ${fail}`);
if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); }
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});
@@ -0,0 +1,285 @@
/**
* D21 — sales-expert login to the customer apps.
*
* Covers exactly what the owner asked for:
* * an expert signs in with their AdminUser mobile, via the normal app flow
* * the master OTP works for an expert too
* * the expert sees THEIR OWN referred customers, and not another expert's
* * the expert can place an order themselves
* * an ordinary customer is refused both expert screens
* * ordinary customer login and ordering still work exactly as before
* * an expert's app token is refused on admin routes, and an admin token is
* refused on expert routes
*
* Every Persian string is a literal here and travels as UTF-8 JSON (D15).
*
* Requires the API running WITH OTP_MASTER_CODE set.
* Usage: node scripts/test-sales-expert-app-login.cjs [apiBaseUrl] [masterCode]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
const MASTER = process.argv[3] || '1379';
let pass = 0, fail = 0;
const failures = [];
const chk = (c, m) => {
if (c) { pass++; console.log(' PASS ' + m); }
else { fail++; failures.push(m); console.log(' FAIL ' + m); }
};
const step = (t) => console.log('\n' + t);
const call = async (method, p, body, token) => {
const res = await fetch(`${API}/${p}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let payload = null;
try { payload = JSON.parse(text); } catch { /* empty */ }
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
const signIn = (mobile, code = MASTER) =>
call('POST', 'customer/auth/verify-otp', { username: mobile, password: code });
/** Everything this run creates, removed at the end. */
const made = { admins: [], customers: [], orders: [] };
const uniq = String(Date.now()).slice(-7);
(async () => {
// ── SETUP: two experts, each with their own referred customer ───────────
step('SETUP — two sales experts, each with one referred customer');
const mkExpert = async (suffix, first, last) => {
const admin = await prisma.adminUser.create({
data: {
fullName: `${first} ${last}`, firstName: first, lastName: last,
mobile: `0915${uniq.slice(0, 3)}${suffix}`,
passwordHash: '!NO_PASSWORD_SET_YET', role: 'SALES_EXPERT', isActive: true,
wagePercent: '4.5',
},
});
await prisma.salesExpertProfile.create({
data: { adminUserId: admin.id, gramLevel: '250.5' },
});
made.admins.push(admin.id);
return admin;
};
const expertA = await mkExpert('001', 'کارشناس', 'الف');
const expertB = await mkExpert('002', 'کارشناس', 'ب');
console.log(` expert A: ${expertA.fullName} (${expertA.mobile})`);
console.log(` expert B: ${expertB.fullName} (${expertB.mobile})`);
const mkCustomer = async (suffix, name, agentId) => {
const c = await prisma.customer.create({
data: {
fullName: name, firstName: name.split(' ')[0], lastName: name.split(' ').slice(1).join(' '),
mobile: `0916${uniq.slice(0, 3)}${suffix}`,
nationalCode: `nc${uniq}${suffix}`,
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
referredByAgentId: agentId, goldCreditLimitGrams: '100',
},
});
made.customers.push(c.id);
return c;
};
const custOfA = await mkCustomer('11', 'مشتری الف', expertA.id);
const custOfB = await mkCustomer('22', 'مشتری ب', expertB.id);
const plainCust = await mkCustomer('33', 'مشتری ساده', null);
console.log(` A's customer: ${custOfA.fullName} · B's: ${custOfB.fullName} · unreferred: ${plainCust.fullName}`);
// ── 1. EXPERT SIGNS IN ──────────────────────────────────────────────────
step('1 — THE EXPERT SIGNS INTO THE APP with their AdminUser mobile');
// requestOtp is what creates the trading account, exactly as a real login would.
const req = await call('POST', 'customer/auth/request-otp', { username: expertA.mobile });
chk(req.ok, `POST customer/auth/request-otp -> ${req.status}`);
const loginA = await signIn(expertA.mobile);
chk(loginA.ok, `verify-otp with the master code -> ${loginA.status}`);
const tokenA = loginA.body?.token;
chk(Boolean(tokenA), 'an app token came back');
const profA = await prisma.salesExpertProfile.findUnique({ where: { adminUserId: expertA.id } });
chk(Boolean(profA?.tradingCustomerId), 'a trading Customer account was created for the expert');
if (profA?.tradingCustomerId) made.customers.push(profA.tradingCustomerId);
const trading = profA?.tradingCustomerId
? await prisma.customer.findUnique({ where: { id: profA.tradingCustomerId } })
: null;
chk(String(trading?.goldCreditLimitGrams) === '250.5',
`the credit limit came from the profile's gramLevel (${trading?.goldCreditLimitGrams})`);
chk(String(trading?.makingChargeOffsetPercent) === '4.5',
`the making charge came from the expert's wagePercent (${trading?.makingChargeOffsetPercent})`);
chk(trading?.referredByAgentId === null,
'the trading account is NOT one of the expert\'s own referred customers');
step('1b — whoami reports an expert');
const who = await call('GET', 'expert/whoami', undefined, tokenA);
chk(who.body?.isSalesExpert === true, `isSalesExpert=${who.body?.isSalesExpert}, type=${who.body?.type}`);
// ── 2. THE EXPERT SEES ONLY THEIR OWN CUSTOMERS ─────────────────────────
step('2 — "انتخاب مشتری": the expert sees THEIR customers only');
const mine = await call('POST', 'expert/my-customers', {}, tokenA);
chk(mine.ok, `POST expert/my-customers -> ${mine.status}`);
const ids = (mine.body ?? []).map((c) => c.id);
chk(ids.includes(custOfA.id), "A's own customer is listed");
chk(!ids.includes(custOfB.id), "NEGATIVE: B's customer is NOT visible to A");
chk(!ids.includes(plainCust.id), 'NEGATIVE: an unreferred customer is not listed');
step('2b — the search filter still scopes to the expert');
const searched = await call('POST', 'expert/my-customers', { search: 'مشتری' }, tokenA);
const sIds = (searched.body ?? []).map((c) => c.id);
chk(sIds.includes(custOfA.id), 'search finds A\'s customer');
chk(!sIds.includes(custOfB.id), "NEGATIVE: search cannot reach B's customer");
// ── 3. CHILD ORDERS ─────────────────────────────────────────────────────
step('3 — "سفارش مشتریان": only this expert\'s customers\' orders, at DELIVERED_TO_AGENT');
const variant = await prisma.productVariant.findFirst({
where: { OR: [{ qty: { gt: 0 } }, { isPreOrder: true }] },
select: { id: true, rashWeightGrams: true, defaultWagePercent: true },
});
const mkOrder = async (customerId, status) => {
const o = await prisma.order.create({
data: {
orderNumber: `T-${uniq}-${Math.floor(Math.random() * 100000)}`,
customerId, status,
totalGoldWeightGrams: '5.5', wageWeightGrams: '0.5',
flatRialCharge: '0', channel: 'WHOLESALE',
},
});
made.orders.push(o.id);
return o;
};
const orderA = await mkOrder(custOfA.id, 'DELIVERED_TO_AGENT');
const orderB = await mkOrder(custOfB.id, 'DELIVERED_TO_AGENT');
const orderAEarly = await mkOrder(custOfA.id, 'PLACED');
const childA = await call('POST', 'expert/child-orders', { page: 1, limit: 50 }, tokenA);
chk(childA.ok, `POST expert/child-orders -> ${childA.status}`);
const cIds = (childA.body?.rows ?? []).map((o) => o.id);
chk(cIds.includes(orderA.id), "A's customer's order is listed");
chk(!cIds.includes(orderB.id), "NEGATIVE: B's customer's order is NOT visible to A");
chk(!cIds.includes(orderAEarly.id),
'NEGATIVE: an order not yet at «تحویل مسئول فروش» is excluded (reference sts=5)');
const row = (childA.body?.rows ?? []).find((o) => o.id === orderA.id);
chk(row?.customer?.name === custOfA.fullName, 'the card carries the customer name');
chk(row?.w !== undefined, 'the card carries the total weight as `w`, as the reference does');
// ── 4. THE EXPERT ORDERS FOR THEMSELVES ─────────────────────────────────
step('4 — the expert places an order of their OWN');
if (variant) {
const ord = await call('POST', 'orders', {
items: [{
productVariantId: variant.id, quantity: 1,
wagePercent: Number(variant.defaultWagePercent),
}],
}, tokenA);
chk(ord.ok, `POST orders as the expert -> ${ord.status}` + (ord.ok ? '' : ` ${ord.raw.slice(0, 160)}`));
const oid = ord.body?.id;
if (oid) made.orders.push(oid);
if (oid) {
const saved = await prisma.order.findUnique({ where: { id: oid } });
chk(saved?.customerId === profA?.tradingCustomerId,
"the order landed on the expert's OWN trading account, not a customer's");
}
step('4b — the expert has the ordinary app too');
const me = await call('GET', 'customer/me', undefined, tokenA);
chk(me.ok, `GET customer/me as the expert -> ${me.status}`);
const myOrders = await call('GET', 'customer/me/orders', undefined, tokenA);
chk(myOrders.ok, `GET customer/me/orders as the expert -> ${myOrders.status}`);
chk((myOrders.body ?? []).some((o) => o.id === oid), "the expert's own order is in their order history");
}
// ── 5. AN ORDINARY CUSTOMER IS REFUSED ──────────────────────────────────
step('5 — an ORDINARY customer cannot reach either expert screen');
const loginC = await signIn(plainCust.mobile);
chk(loginC.ok, `the ordinary customer still signs in -> ${loginC.status}`);
const tokenC = loginC.body?.token;
const whoC = await call('GET', 'expert/whoami', undefined, tokenC);
chk(whoC.body?.isSalesExpert === false, 'whoami says NOT an expert, so the app hides the menu entries');
const cChild = await call('POST', 'expert/child-orders', {}, tokenC);
chk(cChild.status === 403, `NEGATIVE: child-orders as a customer -> ${cChild.status} (expected 403)`);
const cMine = await call('POST', 'expert/my-customers', {}, tokenC);
chk(cMine.status === 403, `NEGATIVE: my-customers as a customer -> ${cMine.status} (expected 403)`);
step('5b — and their ordinary app still works exactly as before');
const cMe = await call('GET', 'customer/me', undefined, tokenC);
chk(cMe.ok && cMe.body?.id === plainCust.id, `GET customer/me -> ${cMe.status}, own record`);
if (variant) {
const cOrd = await call('POST', 'orders', {
items: [{ productVariantId: variant.id, quantity: 1, wagePercent: Number(variant.defaultWagePercent) }],
}, tokenC);
chk(cOrd.ok, `an ordinary customer can still place an order -> ${cOrd.status}`);
if (cOrd.body?.id) {
made.orders.push(cOrd.body.id);
const saved = await prisma.order.findUnique({ where: { id: cOrd.body.id } });
chk(saved?.customerId === plainCust.id, 'it landed on their own account');
}
}
// ── 6. TOKEN BOUNDARIES ─────────────────────────────────────────────────
step('6 — token boundaries');
const adminLogin = await fetch(`${API}/admin/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
});
const adminToken = (await adminLogin.json()).api_token;
const expertOnAdmin = await call('POST', 'admin/customers-omde/list', { page: 1, limit: 1 }, tokenA);
chk(expertOnAdmin.status === 401 || expertOnAdmin.status === 403,
`NEGATIVE: the expert's APP token on an admin route -> ${expertOnAdmin.status}`);
const customerOnAdmin = await call('POST', 'admin/customers-omde/list', { page: 1, limit: 1 }, tokenC);
chk(customerOnAdmin.status === 401 || customerOnAdmin.status === 403,
`NEGATIVE: a customer token on an admin route -> ${customerOnAdmin.status}`);
const adminOnExpert = await call('POST', 'expert/my-customers', {}, adminToken);
chk(adminOnExpert.status === 401,
`NEGATIVE: an ADMIN token on an expert route -> ${adminOnExpert.status} (expected 401)`);
step('6b — a deactivated expert loses app access immediately');
await prisma.adminUser.update({ where: { id: expertA.id }, data: { isActive: false } });
const afterDeactivate = await call('GET', 'expert/me', undefined, tokenA);
chk(afterDeactivate.status === 401,
`NEGATIVE: the same token after deactivation -> ${afterDeactivate.status} (expected 401)`);
await prisma.adminUser.update({ where: { id: expertA.id }, data: { isActive: true } });
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
await prisma.orderItem.deleteMany({ where: { orderId: { in: made.orders } } });
await prisma.orderStatusEvent.deleteMany({ where: { orderId: { in: made.orders } } });
await prisma.ledgerEntry.deleteMany({ where: { orderId: { in: made.orders } } });
await prisma.order.deleteMany({ where: { id: { in: made.orders } } });
await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } });
await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: made.admins } } });
await prisma.customer.deleteMany({ where: { id: { in: made.customers } } });
await prisma.adminUser.deleteMany({ where: { id: { in: made.admins } } });
console.log(
` deleted ${made.orders.length} order(s), ${made.customers.length} customer(s) ` +
`(including the expert trading account this run created) and ${made.admins.length} admin user(s). ` +
'All were created by this run; no pre-existing data was touched.',
);
console.log(`\n${'='.repeat(60)}`);
console.log(`SALES-EXPERT APP LOGIN: passed ${pass} failed ${fail}`);
if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); }
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});
@@ -0,0 +1,172 @@
/**
* D12 rule E0 round-trip test for the SALES EXPERT entity, plus the two grid
* filters D17 required.
*
* All Persian input is written here as literals and sent as UTF-8 JSON, never
* through a shell argument (D15).
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
// Port 3000 is API_PORT in the repo-root .env; overridable for another setup.
// (Was hardcoded to 3077, a port from an earlier dev session, so the script
// could only ever connect by accident.)
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
let pass = 0, fail = 0;
const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); };
const FIRST = 'محمد علی';
const LAST = 'احمدی';
async function login() {
const r = await fetch(API + '/admin/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
});
return (await r.json()).api_token;
}
const snap = async (id) => {
const u = await prisma.adminUser.findUnique({
where: { id }, include: { salesExpertProfile: true },
});
const { updatedAt, ...profile } = u.salesExpertProfile ?? {};
return JSON.stringify({
fullName: u.fullName, firstName: u.firstName, lastName: u.lastName,
mobile: u.mobile, role: u.role, wagePercent: String(u.wagePercent),
address: u.address, storeName: u.storeName,
profile: { ...profile, gramLevel: String(profile.gramLevel) },
});
};
(async () => {
const token = await login();
const H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token };
const city = await prisma.city.findFirst({ select: { id: true, name: true } });
const city2 = await prisma.city.findFirst({ where: { id: { not: city.id } }, select: { id: true, name: true } });
const mobile = '0912' + String(Date.now()).slice(-7);
console.log('STEP 1 — create a sales expert with EVERY field populated');
const createBody = {
fname: FIRST, lname: LAST, mobile, password: 'Test12345',
role: 'SALES_EXPERT', percent: 4.5,
city_id: city.id, gram_level: 250.5, phone: '02188887777', ncode: '1234567890',
address: 'تهران، خیابان ولیعصر', store_name: 'طلای محمد', pasazh_name: 'پاساژ بزرگ', mcode: 'SE-001',
};
const cr = await fetch(API + '/admin/users', { method: 'POST', headers: H, body: JSON.stringify(createBody) });
const created = await cr.json();
if (!created.id) { console.log(' create failed:', JSON.stringify(created).slice(0, 300)); process.exit(1); }
const id = created.id;
console.log(' id:', id);
const row = await prisma.adminUser.findUnique({ where: { id }, include: { salesExpertProfile: true } });
chk(row.firstName === FIRST, `firstName stored as «${FIRST}» — a two-token first name is NOT re-split`);
chk(row.lastName === LAST, `lastName stored as «${LAST}»`);
chk(row.fullName === `${FIRST} ${LAST}`, 'fullName rebuilt by the trigger');
chk(!!row.salesExpertProfile, 'SalesExpertProfile auto-created on create');
chk(row.salesExpertProfile?.cityId === city.id, 'profile.cityId stored');
chk(String(row.salesExpertProfile?.gramLevel) === '250.5', 'profile.gramLevel stored (250.5)');
chk(row.salesExpertProfile?.phone === '02188887777', 'profile.phone stored');
chk(row.salesExpertProfile?.nationalCode === '1234567890', 'profile.nationalCode stored');
console.log('\nSTEP 2 — open the edit form: every field populated, separately');
const g = await (await fetch(API + `/admin/users/${id}`, { headers: H })).json();
chk(g.fname === FIRST, `fname === «${FIRST}» (NOT the whole name)`);
chk(g.lname === LAST, `lname === «${LAST}» (NOT empty)`);
chk(Number(g.percent) === 4.5, 'percent === 4.5');
const listed = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H, body: JSON.stringify({ page: 1, limit: 200 }),
})).json();
const mine = (listed.rows ?? []).find((x) => x.id === id);
chk(!!mine, 'the new expert appears in the reagent grid');
chk(mine?.fname === FIRST && mine?.lname === LAST, 'grid shows the real name parts');
chk(mine?.city_id === city.id, 'grid returns a REAL city_id (was hardcoded null)');
chk(mine?.city?.title === city.name, 'grid returns the city object for the column');
chk(String(mine?.gram_level) === '250.5', 'grid returns a REAL gram_level (was hardcoded null)');
chk(mine?.phone === '02188887777', 'grid returns phone');
chk(mine?.ncode === '1234567890', 'grid returns ncode');
console.log('\nSTEP 3 — save with NO changes: the row must be byte-identical');
const before = await snap(id);
await fetch(API + `/admin/users/${id}`, {
method: 'PUT', headers: H,
body: JSON.stringify({
fname: g.fname, lname: g.lname, mobile: g.mobile, role: 'SALES_EXPERT',
percent: Number(g.percent), city_id: mine.city_id, gram_level: Number(mine.gram_level),
phone: mine.phone, ncode: mine.ncode, address: g.address,
store_name: g.store_name, pasazh_name: g.pasazh_name, mcode: g.mcode,
}),
});
const after = await snap(id);
chk(before === after, 'no-op save changed nothing');
if (before !== after) console.log(' before: ' + before + '\n after : ' + after);
console.log('\nSTEP 4a — change ONLY lastName');
await fetch(API + `/admin/users/${id}`, {
method: 'PUT', headers: H, body: JSON.stringify({ lname: 'محمدی' }),
});
const s4 = JSON.parse(await snap(id));
const b4 = JSON.parse(after);
chk(s4.lastName === 'محمدی' && s4.firstName === FIRST, `only lastName changed; firstName still «${FIRST}»`);
chk(s4.fullName === `${FIRST} محمدی`, 'fullName rebuilt by the trigger');
chk(JSON.stringify(s4.profile) === JSON.stringify(b4.profile), 'the profile was untouched');
console.log('\nSTEP 4b — change ONLY gramLevel');
const b5 = JSON.parse(await snap(id));
await fetch(API + `/admin/users/${id}`, {
method: 'PUT', headers: H, body: JSON.stringify({ gram_level: 999.25 }),
});
const s5 = JSON.parse(await snap(id));
const diff = Object.keys(s5.profile).filter((k) => String(s5.profile[k]) !== String(b5.profile[k]));
chk(diff.length === 1 && diff[0] === 'gramLevel', 'only gramLevel changed: ' + diff.join(','));
chk(s5.firstName === b5.firstName && s5.lastName === b5.lastName, 'the name was untouched');
console.log('\nSTEP 5 — the two grid FILTERS');
const byCity = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H,
body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'city_id', op: '=', value: city.id }] }),
})).json();
chk((byCity.rows ?? []).some((x) => x.id === id), `city filter (${city.name}) returns the expert`);
chk((byCity.rows ?? []).every((x) => x.city_id === city.id), 'city filter returns ONLY that city');
if (city2) {
const otherCity = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H,
body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'city_id', op: '=', value: city2.id }] }),
})).json();
chk(!(otherCity.rows ?? []).some((x) => x.id === id), `filtering by a DIFFERENT city (${city2.name}) excludes it`);
}
const byGram = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H,
body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'gram_level', op: '=', value: 999.25 }] }),
})).json();
chk((byGram.rows ?? []).some((x) => x.id === id), 'gram_level filter returns the expert');
chk((byGram.rows ?? []).every((x) => String(x.gram_level) === '999.25'), 'gram_level filter returns ONLY matches');
console.log('\nSTEP 6 — an expert with NO profile still lists (left join)');
const orphan = await prisma.adminUser.create({
data: { fullName: 'بدون پروفایل', firstName: 'بدون', lastName: 'پروفایل',
mobile: '0913' + String(Date.now()).slice(-7), passwordHash: 'x', role: 'SALES_EXPERT' },
});
const l2 = await (await fetch(API + '/admin/customers-omde/reagent/list', {
method: 'POST', headers: H, body: JSON.stringify({ page: 1, limit: 200 }),
})).json();
const o = (l2.rows ?? []).find((x) => x.id === orphan.id);
chk(!!o, 'an expert without a profile still appears in the grid');
chk(o?.city_id === null && o?.gram_level === null, 'its city/gram cells are null, not an error');
console.log('\nSTEP 7 — the referrer dropdown lists every active expert');
const opts = await (await fetch(API + '/admin/customers-omde/query/reagent', { headers: H })).json();
chk(opts.some((x) => x.id === id), 'the new expert is selectable as a referrer');
chk(opts.some((x) => x.id === orphan.id), 'the profile-less expert is selectable too');
await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: [id, orphan.id] } } });
await prisma.adminUser.deleteMany({ where: { id: { in: [id, orphan.id] } } });
console.log(`\nCleanup: deleted 2 test AdminUser rows (${id}, ${orphan.id}) and their profiles.`);
console.log(`\npassed: ${pass} failed: ${fail}`);
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });