feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
+310 -13
View File
@@ -1,9 +1,27 @@
import { AdminRole, PrismaClient, VerificationStep } from '@prisma/client';
import {
AdminRole,
Customer,
CustomerKind,
OrderChannel,
PrismaClient,
VerificationStep,
} from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { ALL_RIDS } from '../src/modules/auth/acl.constants';
const prisma = new PrismaClient();
// Placeholder — replace with a real bcrypt hash once the auth module lands.
const PLACEHOLDER_PASSWORD_HASH = 'placeholder-hash-not-for-production';
// Real bcrypt hash of DEV_PASSWORD, so seeded accounts can actually log in.
// Development credentials only — never seed these into a real deployment.
const DEV_PASSWORD = 'admin1234';
const PLACEHOLDER_PASSWORD_HASH = bcrypt.hashSync(DEV_PASSWORD, 10);
// A single number authorized to sign in to BOTH apps for local development,
// with its password (admin panel) equal to the number itself. DEV-ONLY: this
// file is never run against a real deployment — only by hand via
// `pnpm prisma:seed` / `npx prisma db seed` against a local database.
const DEV_TEST_PHONE = '09128081392';
const DEV_TEST_PHONE_PASSWORD_HASH = bcrypt.hashSync(DEV_TEST_PHONE, 10);
// Served from the user-app's own public/ folder. This is the same placeholder
// image the legacy app used for missing/failed product imagery (no-items.png).
@@ -36,68 +54,167 @@ async function main() {
create: { id: 'workshop-central', name: 'کارگاه مرکزی ساخت طلا' },
});
// ── Admin user types + permissions (legacy `permission(rid, ut)`) ────
//
// In legacy these lived in the dynamic `config_static` lookup under the key
// `__list__ut` and were created from the UI (views/user/Permission.vue), so
// there is no legacy seeder to copy. Id 85 is seeded because Permission.vue
// hardcodes it as the default selection (`setFieldsValue({ut:"85"})`).
//
// Only leaf rids are stored; ancestors are derived at runtime by
// expandWithParents() / checkParent(), exactly as legacy did.
const userTypes: { id: number; title: string; rids: readonly string[] }[] = [
// Full access — every rid the routers gate.
{ id: 85, title: 'مدیر ارشد', rids: ALL_RIDS },
{
id: 86,
title: 'کارشناس فروش',
rids: [
'order.list', 'order.create', 'order.omde', 'order.makeOrder',
'persons.list.client.omde', 'persons.list.client.omde.view',
'persons.list.client.single', 'persons.list.client.single.view',
'persons.create', 'product.list', 'dashboard.tak', 'comment',
],
},
{
id: 87,
title: 'حسابدار',
rids: [
'document.list', 'document.create', 'document.list.edit',
'invoice.list', 'invoice.create', 'wallet', 'config.wallet',
'report.order.item', 'report.product', 'config.report', 'dashboard.omde',
],
},
{
id: 88,
title: 'سرپرست کارگاه',
rids: ['workshop.list', 'workshop.create', 'order.tamin', 'order.list', 'product.list'],
},
{
id: 89,
title: 'کاربر شعبه',
rids: ['buy.order.list', 'order.create', 'persons.create', 'branch.list'],
},
];
for (const ut of userTypes) {
await prisma.userType.upsert({
where: { id: ut.id },
update: { title: ut.title },
create: { id: ut.id, title: ut.title },
});
await prisma.permission.createMany({
data: ut.rids.map((rid) => ({ rid, userTypeId: ut.id })),
skipDuplicates: true,
});
}
// ── Admin users (sales agents + one super admin) ────────────────────
// DEV-ONLY test login, per explicit request: authorized in BOTH the admin
// panel and the user-app, password = the phone number itself. Super admin
// so it can reach every screen while testing. Do not carry this into a real
// deployment's seed data.
await prisma.adminUser.upsert({
where: { mobile: DEV_TEST_PHONE },
update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null },
create: {
mobile: DEV_TEST_PHONE,
fullName: 'کاربر تست توسعه',
passwordHash: DEV_TEST_PHONE_PASSWORD_HASH,
role: AdminRole.SUPER_ADMIN,
userTypeId: 85,
branchId: null,
},
});
await prisma.adminUser.upsert({
where: { mobile: '09120000001' },
update: {},
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null },
create: {
mobile: '09120000001',
fullName: 'مدیر ارشد سیستم',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SUPER_ADMIN,
branchId: branchTehran.id,
userTypeId: 85,
// Head office: deliberately branch-less. Legacy scoped every list by
// branch_id whenever the user had one, so head-office staff must have none
// or they would see only a single branch's data.
branchId: null,
},
});
const agentReza = await prisma.adminUser.upsert({
where: { mobile: '09121234567' },
update: {},
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 86 },
create: {
mobile: '09121234567',
fullName: 'رضا احمدی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SALES_EXPERT,
userTypeId: 86,
branchId: branchTehran.id,
wagePercent: 1.5,
},
});
const agentSara = await prisma.adminUser.upsert({
where: { mobile: '09129876543' },
update: {},
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 86 },
create: {
mobile: '09129876543',
fullName: 'سارا کریمی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SALES_EXPERT,
userTypeId: 86,
branchId: branchMashhad.id,
wagePercent: 1.2,
},
});
await prisma.adminUser.upsert({
where: { mobile: '09123334444' },
update: {},
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 88 },
create: {
mobile: '09123334444',
fullName: 'هومن یزدانی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.WORKSHOP_SUPERVISOR,
userTypeId: 88,
},
});
await prisma.adminUser.upsert({
where: { mobile: '09125556666' },
update: {},
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null },
create: {
mobile: '09125556666',
fullName: 'فرزانه محمودی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.ACCOUNTANT,
branchId: branchTehran.id,
userTypeId: 87,
branchId: null,
},
});
// ── Customers ────────────────────────────────────────────────────────
const customerData = [
{
// DEV-ONLY test login, per explicit request: same number as the admin
// panel account above, so it can also sign in to the user-app (OTP step
// matches on mobile against a real seeded customer — see LoginView.vue).
// Fully verified so no onboarding step blocks it. Not for a real deployment.
id: 'customer-dev-test',
kind: CustomerKind.APP_SHOPKEEPER,
memberCode: 'DEV-TEST',
fullName: 'کاربر تست توسعه',
mobile: DEV_TEST_PHONE,
nationalCode: '0000000000',
cityId: tehran.id,
referredByAgentId: null,
goldCreditLimitGrams: '1000',
verificationStep: VerificationStep.VERIFIED,
},
{
id: 'customer-1',
kind: CustomerKind.BANKER,
storeName: 'طلای محمدی', pasazhName: 'پاساژ بزرگ', zone: 'بازار', memberCode: 'M-1001', phone: '02133445566', address: 'تهران، بازار بزرگ، پلاک ۱۲',
fullName: 'علی محمدی',
mobile: '09151112233',
nationalCode: '0012345678',
@@ -108,6 +225,8 @@ async function main() {
},
{
id: 'customer-2',
kind: CustomerKind.APP_SHOPKEEPER,
storeName: 'گالری حسینی', zone: 'ونک', memberCode: 'M-1002', phone: '02188776655',
fullName: 'مریم حسینی',
mobile: '09151112234',
nationalCode: '0012345679',
@@ -118,6 +237,8 @@ async function main() {
},
{
id: 'customer-3',
kind: CustomerKind.WALK_IN_SHOPKEEPER,
storeName: 'زرگری نوروزی', pasazhName: 'پاساژ رضا', zone: 'طرق', memberCode: 'M-1003',
fullName: 'جواد نوروزی',
mobile: '09151112235',
nationalCode: '0012345680',
@@ -128,6 +249,8 @@ async function main() {
},
{
id: 'customer-4',
kind: CustomerKind.APP_SHOPKEEPER,
memberCode: 'M-1004',
fullName: 'نرگس ابراهیمی',
mobile: '09151112236',
nationalCode: '0012345681',
@@ -135,9 +258,14 @@ async function main() {
referredByAgentId: null,
goldCreditLimitGrams: '30',
verificationStep: VerificationStep.UNVERIFIED,
// archived, so the "لیست بایگانی" grid has a row and the type lists
// correctly exclude it
isArchived: true,
},
{
id: 'customer-5',
kind: CustomerKind.BANKER,
storeName: 'صادقی و پسران', zone: 'بازار', memberCode: 'M-1005', phone: '02155443322',
fullName: 'کامران صادقی',
mobile: '09151112237',
nationalCode: '0012345682',
@@ -148,6 +276,8 @@ async function main() {
},
{
id: 'customer-6',
kind: CustomerKind.WALK_IN_SHOPKEEPER,
storeName: 'طلاسرای قاسمی', zone: 'ائل گلی', memberCode: 'M-1006',
fullName: 'زهرا قاسمی',
mobile: '09151112238',
nationalCode: '0012345683',
@@ -158,12 +288,25 @@ async function main() {
},
];
const customers = [];
const customers: Customer[] = [];
for (const c of customerData) {
customers.push(
await prisma.customer.upsert({
where: { id: c.id },
update: {},
// Re-assert the DESCRIPTIVE fields on an existing row so a re-seed
// backfills columns added after that row was first created. Balances,
// verification state and credit limits are deliberately NOT touched —
// those are live data a re-seed must never overwrite.
update: {
kind: c.kind,
phone: 'phone' in c ? c.phone : null,
address: 'address' in c ? c.address : null,
storeName: 'storeName' in c ? c.storeName : null,
pasazhName: 'pasazhName' in c ? c.pasazhName : null,
zone: 'zone' in c ? c.zone : null,
memberCode: c.memberCode,
isArchived: 'isArchived' in c ? c.isArchived : false,
},
create: {
id: c.id,
fullName: c.fullName,
@@ -174,11 +317,25 @@ async function main() {
referredByAgentId: c.referredByAgentId,
goldCreditLimitGrams: c.goldCreditLimitGrams,
verificationStep: c.verificationStep,
kind: c.kind,
phone: 'phone' in c ? c.phone : null,
address: 'address' in c ? c.address : null,
storeName: 'storeName' in c ? c.storeName : null,
pasazhName: 'pasazhName' in c ? c.pasazhName : null,
zone: 'zone' in c ? c.zone : null,
memberCode: c.memberCode,
isArchived: 'isArchived' in c ? c.isArchived : false,
},
}),
);
}
const [customer1, customer2, , , customer5] = customers;
// Looked up by id rather than array position — a positional destructure here
// silently breaks (wrong customer, no error) the moment a row is added or
// reordered above, as happened when the dev-test login row was inserted first.
const byId = (id: string) => customers.find((c) => c.id === id)!;
const customer1 = byId('customer-1');
const customer2 = byId('customer-2');
const customer5 = byId('customer-5');
// ── Catalog: categories, brands, products, variants ────────────────
const [ringsCategory, braceletsCategory, necklacesCategory, earringsCategory, coinsCategory] = await Promise.all([
@@ -331,6 +488,56 @@ async function main() {
data: { karatPerMille: 750, pricePerGramRial: '61500000', source: 'seed-illustrative' },
});
// A RETAIL order, so the single-sale grid (legacy `POST single-order`) has a
// row of its own. Legacy kept the two channels in separate tables; here they
// are separated by Order.channel.
const existingRetail = await prisma.order.findUnique({
where: { orderNumber: 'ORD-SEED-0002' },
});
if (!existingRetail) {
const rash = '2.400';
const wage = 7.5;
const computed = (parseFloat(rash) * (1 + wage / 100)).toFixed(4);
await prisma.order.create({
data: {
orderNumber: 'ORD-SEED-0002',
customerId: customer2.id,
branchId: branchTehran.id,
channel: OrderChannel.RETAIL,
status: 'PLACED',
totalGoldWeightGrams: computed,
wageWeightGrams: (parseFloat(computed) - parseFloat(rash)).toFixed(4),
flatRialCharge: '0',
items: {
create: [
{
productVariantId: 'variant-ring-size-7',
quantity: 1,
rashWeightGramsSnapshot: rash,
wagePercentSnapshot: wage.toString(),
discountPercentSnapshot: '0',
computedWeightGrams: computed,
flatRialCharge: '0',
},
],
},
statusEvents: { create: [{ toStatus: 'PLACED' }] },
},
});
}
// Order.channel was added after these demo rows first existed, so a re-seed
// corrects the channel on the two named seed orders. Scoped to those exact
// order numbers — it must never rewrite real orders.
await prisma.order.updateMany({
where: { orderNumber: 'ORD-SEED-0001' },
data: { channel: OrderChannel.WHOLESALE },
});
await prisma.order.updateMany({
where: { orderNumber: 'ORD-SEED-0002' },
data: { channel: OrderChannel.RETAIL },
});
// ── A delivered order + posted debt, so the dashboard shows real numbers ──
const existingDemoOrder = await prisma.order.findUnique({ where: { orderNumber: 'ORD-SEED-0001' } });
if (!existingDemoOrder) {
@@ -346,6 +553,8 @@ async function main() {
orderNumber: 'ORD-SEED-0001',
customerId: customer1.id,
branchId: branchTehran.id,
// customer1 is a بنکدار, so this belongs to the wholesale grid
channel: OrderChannel.WHOLESALE,
status: 'DELIVERED_TO_CUSTOMER',
totalGoldWeightGrams: computedWeight,
wageWeightGrams: (parseFloat(computedWeight) - parseFloat(rashWeight)).toFixed(4),
@@ -420,6 +629,94 @@ async function main() {
await prisma.customer.update({ where: { id: customer2.id }, data: { goldDebtGrams: '8.2500', fiatDebtRial: '2000000' } });
await prisma.customer.update({ where: { id: customer5.id }, data: { goldDebtGrams: '0', fiatDebtRial: '0' } });
// ── Pre-orders (legacy "سفارش های ساختنی" / custom orders) ──────────
// Design requests an admin prices and later converts into a real Order.
// Seeded so the MakeOrder grid has rows to render.
const preOrderSeeds = [
{
id: 'preorder-0001',
customerId: customer2.id,
cityId: tehran.id,
status: 'PLACED' as const,
colorName: 'رزگلد',
sizeSpec: '۱۶',
stoneColor: 'سفید',
minaColor: 'آبی',
description: 'انگشتر طرح سنتی با نگین سفید',
estimatedWeightGrams: '4.5000',
},
{
id: 'preorder-0002',
customerId: customer5.id,
cityId: mashhad.id,
status: 'ADMIN_APPROVED' as const,
colorName: 'زرد',
sizeSpec: '۱۸',
stoneColor: 'قرمز',
minaColor: 'سبز',
description: 'دستبند مینا کاری',
estimatedWeightGrams: '9.2500',
},
];
for (const po of preOrderSeeds) {
await prisma.preOrder.upsert({
where: { id: po.id },
update: {},
create: po,
});
}
// ── Product comments (legacy `comment` table) ───────────────────────
// Seeded across all three moderation states so the Comments grid and its
// status filter have something to show.
const allProducts = await prisma.product.findMany({ take: 3 });
if (allProducts.length > 0) {
const commentSeeds = [
{
id: 'comment-0001',
productId: allProducts[0].id,
customerId: customer2.id,
body: 'کیفیت ساخت عالی بود، ممنون از ارسال سریع.',
status: 'APPROVED' as const,
},
{
id: 'comment-0002',
productId: allProducts[0].id,
customerId: customer5.id,
body: 'آیا این مدل در سایز بزرگ‌تر هم موجود می‌شود؟',
status: 'PENDING' as const,
},
{
id: 'comment-0003',
productId: allProducts[Math.min(1, allProducts.length - 1)].id,
customerId: customer2.id,
body: 'وزن با آنچه نوشته شده کمی تفاوت داشت.',
status: 'REJECTED' as const,
},
];
for (const c of commentSeeds) {
await prisma.comment.upsert({ where: { id: c.id }, update: {}, create: c });
}
}
// ── Variant stock + product codes (columns added in Batch 6) ────────
// Without stock every variant reads as out-of-stock, which the cart guard
// rejects, so the order builder would have nothing to add.
const allVariants = await prisma.productVariant.findMany();
for (const [i, v] of allVariants.entries()) {
await prisma.productVariant.update({
where: { id: v.id },
data: { qty: 5 + (i % 4), isPreOrder: i % 5 === 0 },
});
}
const codedProducts = await prisma.product.findMany({ orderBy: { title: 'asc' } });
for (const [i, p] of codedProducts.entries()) {
await prisma.product.update({
where: { id: p.id },
data: { productCode: `P-${String(i + 1).padStart(4, '0')}` },
});
}
console.log('Seed complete:', {
cities: 4,
branches: 2,