feat: complete admin panel and backend admin api migration
This commit is contained in:
+310
-13
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user