feat: complete admin panel and backend admin api migration
This commit is contained in:
+471
-37
@@ -5,6 +5,8 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
// native = local Windows/macOS; debian-openssl-3.0.x = node:20-bookworm-slim (Arvan)
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -24,6 +26,16 @@ enum AdminRole {
|
||||
ACCOUNTANT
|
||||
}
|
||||
|
||||
/// Legacy Customer.type — what kind of wholesale customer this is. Values and
|
||||
/// labels taken verbatim from bulkCustomer.vue's userTypes list.
|
||||
enum CustomerKind {
|
||||
SALES_AGENT // 1 کارشناس فروش
|
||||
APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن
|
||||
WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری
|
||||
BANKER // 4 بنکدار
|
||||
WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه
|
||||
}
|
||||
|
||||
enum VerificationStep {
|
||||
UNVERIFIED
|
||||
STEP_ONE_SUBMITTED
|
||||
@@ -31,6 +43,14 @@ enum VerificationStep {
|
||||
VERIFIED
|
||||
}
|
||||
|
||||
/// Legacy kept retail and wholesale orders in two SEPARATE tables
|
||||
/// (orders_orders vs orders). The redesigned schema has one Order model, so the
|
||||
/// distinction lives here instead.
|
||||
enum OrderChannel {
|
||||
RETAIL // تک فروشی
|
||||
WHOLESALE // عمده فروشی
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
SUSPENDED // credit limit (goldCreditLimitGrams) exceeded at placement
|
||||
PLACED
|
||||
@@ -77,13 +97,17 @@ enum LedgerSourceType {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model City {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
province String?
|
||||
customers Customer[]
|
||||
addresses CustomerAddress[]
|
||||
branches Branch[]
|
||||
preOrders PreOrder[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
province String?
|
||||
/// Legacy cities.percent / percent_aux — per-city wage surcharges the city
|
||||
/// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}.
|
||||
percent Decimal? @db.Decimal(6, 3)
|
||||
percentAux Decimal? @db.Decimal(6, 3)
|
||||
customers Customer[]
|
||||
addresses CustomerAddress[]
|
||||
branches Branch[]
|
||||
preOrders PreOrder[]
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
@@ -92,6 +116,11 @@ model AdminUser {
|
||||
mobile String @unique
|
||||
passwordHash String
|
||||
role AdminRole
|
||||
/// Legacy `users.ut`. Granular ACL lives here (Permission rows hang off UserType);
|
||||
/// the coarse `role` enum above is retained for the business rules that already
|
||||
/// switch on it. Nullable so existing seeded admins remain valid.
|
||||
userTypeId Int?
|
||||
userType UserType? @relation(fields: [userTypeId], references: [id])
|
||||
branchId String?
|
||||
branch Branch? @relation(fields: [branchId], references: [id])
|
||||
wagePercent Decimal? @db.Decimal(6, 3) // legacy Customer.percent, commission on referred orders
|
||||
@@ -99,11 +128,17 @@ model AdminUser {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
referredCustomers Customer[] @relation("ReferralAgent")
|
||||
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
||||
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
||||
postedLedgerEntries LedgerEntry[]
|
||||
orderStatusEvents OrderStatusEvent[]
|
||||
preOrderStatusEvents PreOrderStatusEvent[]
|
||||
sentSmsMessages SmsMessage[] @relation("SmsAuthor")
|
||||
sentNotifications Notification[] @relation("NotificationAuthor")
|
||||
uploadedFiles UploadedFile[] @relation("FileUploader")
|
||||
|
||||
@@index([role])
|
||||
@@index([userTypeId])
|
||||
}
|
||||
|
||||
model Customer {
|
||||
@@ -115,6 +150,20 @@ model Customer {
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
|
||||
/// Legacy Customer.type — distinguishes بنکدار from مغازه دار, which the
|
||||
/// BankerList / ShopkeeperList screens filter on.
|
||||
kind CustomerKind @default(APP_SHOPKEEPER)
|
||||
|
||||
/// Legacy contact + shop columns, shown on the wholesale customer form and as
|
||||
/// (hidden-by-default) columns in its grid.
|
||||
phone String?
|
||||
address String?
|
||||
storeName String?
|
||||
pasazhName String?
|
||||
zone String?
|
||||
/// Legacy mcode — the operator-assigned customer code.
|
||||
memberCode String?
|
||||
|
||||
referredByAgentId String?
|
||||
referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id])
|
||||
|
||||
@@ -129,15 +178,33 @@ model Customer {
|
||||
businessLicenseUrl String?
|
||||
shopImageUrl String?
|
||||
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
/// Legacy `image_id` — the profile photo (عکس پروفایل) on the sales-agent
|
||||
/// form. Legacy stored an FK to its Image table and read it back as a nested
|
||||
/// `image: { id, image }`, so this is a relation, not a bare URL string.
|
||||
avatarFileId String?
|
||||
avatarFile UploadedFile? @relation("CustomerAvatar", fields: [avatarFileId], references: [id])
|
||||
|
||||
/// Legacy had TWO independent switches on a customer:
|
||||
/// sts -> may they place orders (this field)
|
||||
/// active -> is the account enabled (isActive, below)
|
||||
/// The port previously drove both from isActive; they are separate again.
|
||||
canPlaceOrders Boolean @default(true)
|
||||
|
||||
isActive Boolean @default(true)
|
||||
/// Legacy archive flag — the archived list filtered on this, not on isActive.
|
||||
isArchived Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
addresses CustomerAddress[]
|
||||
orders Order[]
|
||||
preOrders PreOrder[]
|
||||
ledgerEntries LedgerEntry[]
|
||||
walletBonuses WalletBonus[]
|
||||
comments Comment[]
|
||||
promotions Promotion[]
|
||||
notifications NotificationRecipient[]
|
||||
activityLogs CustomerActivityLog[]
|
||||
|
||||
@@index([referredByAgentId])
|
||||
}
|
||||
@@ -153,19 +220,31 @@ model CustomerAddress {
|
||||
}
|
||||
|
||||
model Branch {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
adminUsers AdminUser[]
|
||||
orders Order[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
/// Legacy Branch.address / phone — both are columns in the branch grid.
|
||||
address String?
|
||||
phone String?
|
||||
/// Legacy GET branch/active/{id} toggles this.
|
||||
isActive Boolean @default(true)
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
adminUsers AdminUser[]
|
||||
orders Order[]
|
||||
allocations BranchAllocation[]
|
||||
}
|
||||
|
||||
model Workshop {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
isActive Boolean @default(true)
|
||||
preOrders PreOrder[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
/// Legacy Workshop.address / phone — both are columns in the workshop grid.
|
||||
address String?
|
||||
phone String?
|
||||
/// The supervisor created alongside the workshop by the legacy Add form.
|
||||
supervisorId String?
|
||||
supervisor AdminUser? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
preOrders PreOrder[]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -173,18 +252,29 @@ model Workshop {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
parentId String?
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
|
||||
children Category[] @relation("CategoryTree")
|
||||
products Product[]
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
/// Legacy Category.type — which storefront the category belongs to:
|
||||
/// 1 سایت | 2 اپلیکشن موبایل. Drives the "type_str" grid column.
|
||||
type Int @default(1)
|
||||
/// Legacy Category.image / icon — the grid renders the image, the storefront the icon.
|
||||
imageUrl String?
|
||||
iconUrl String?
|
||||
/// Legacy meta tag + description, used for SEO on the site categories.
|
||||
metaTag String?
|
||||
description String?
|
||||
parentId String?
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
|
||||
children Category[] @relation("CategoryTree")
|
||||
products Product[]
|
||||
promotions Promotion[]
|
||||
}
|
||||
|
||||
model Brand {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
products Product[]
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
products Product[]
|
||||
promotions Promotion[]
|
||||
}
|
||||
|
||||
/// Curated product groupings — replaces legacy "Ciny/CinyProduct" (a tagging
|
||||
@@ -214,12 +304,19 @@ model Product {
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
brandId String?
|
||||
brand Brand? @relation(fields: [brandId], references: [id])
|
||||
/// Legacy Product.product_code — the human-facing SKU shown in the grid.
|
||||
productCode String? @unique
|
||||
/// Legacy Product.show_type: 1 site, 2 mobile, 3 both.
|
||||
showType Int @default(3)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
collections CollectionProduct[]
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
collections CollectionProduct[]
|
||||
comments Comment[]
|
||||
promotions Promotion[]
|
||||
allocationItems BranchAllocationItem[]
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
@@ -240,6 +337,12 @@ model ProductVariant {
|
||||
rashWeightGrams Decimal @db.Decimal(14, 4)
|
||||
/// Default making-charge percentage applied on top of rash weight.
|
||||
defaultWagePercent Decimal @db.Decimal(6, 3)
|
||||
/// Legacy ProductVariant.qty — on-hand stock. The cart refuses a variant with
|
||||
/// qty 0 unless it is flagged pre-order, so both fields are required for that
|
||||
/// guard to work (see apps/admin-panel/src/stores/cart.ts).
|
||||
qty Int @default(0)
|
||||
/// Legacy is_pre_order: orderable even at zero stock.
|
||||
isPreOrder Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
orderItems OrderItem[]
|
||||
@@ -250,13 +353,15 @@ model ProductVariant {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Order {
|
||||
id String @id @default(cuid())
|
||||
orderNumber String @unique
|
||||
id String @id @default(cuid())
|
||||
orderNumber String @unique
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
branchId String?
|
||||
branch Branch? @relation(fields: [branchId], references: [id])
|
||||
status OrderStatus @default(PLACED)
|
||||
branch Branch? @relation(fields: [branchId], references: [id])
|
||||
/// Which sales channel placed this order — see OrderChannel.
|
||||
channel OrderChannel @default(RETAIL)
|
||||
status OrderStatus @default(PLACED)
|
||||
|
||||
/// Total gold weight incl. wage across all items ("allGram" / order.w).
|
||||
totalGoldWeightGrams Decimal @db.Decimal(14, 4)
|
||||
@@ -389,6 +494,22 @@ model LedgerEntry {
|
||||
postedByAdminUserId String?
|
||||
postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id])
|
||||
|
||||
/// Instrument metadata from the legacy "cash" document form. Which fields are
|
||||
/// filled depends on sourceType: a melted-gold receipt carries lab/hallmark and
|
||||
/// the pre-normalization weight, a cheque carries the account and bank, and a
|
||||
/// transfer carries its destination.
|
||||
/// Legacy columns: lab, ang, account_name, bank_name, to, original_value, recive_date.
|
||||
labName String?
|
||||
hallmark String?
|
||||
accountName String?
|
||||
bankName String?
|
||||
transferTo String?
|
||||
/// Weight as handed over, BEFORE karat normalization (legacy original_value).
|
||||
originalValue Decimal? @db.Decimal(14, 4)
|
||||
/// Rial amount for cash/cheque documents (legacy price).
|
||||
priceRial Decimal? @db.Decimal(20, 2)
|
||||
receivedAt DateTime?
|
||||
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -421,3 +542,316 @@ model WalletBonus {
|
||||
|
||||
@@index([customerId, isActive])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Admin ACL — faithful port of the legacy granular permission model.
|
||||
//
|
||||
// Legacy (henza_server):
|
||||
// Schema::create('permission', function (Blueprint $table) {
|
||||
// $table->string('rid');
|
||||
// $table->integer('ut');
|
||||
// $table->primary(['rid' , 'ut']);
|
||||
// });
|
||||
//
|
||||
// Permissions attach to the USER TYPE (`ut`), never to an individual user, so
|
||||
// every admin of a given type shares one permission set. `ut` pointed at a row
|
||||
// in the legacy `config_static` lookup table; UserType is that table, narrowed
|
||||
// to the admin-user-type rows it was actually used for.
|
||||
//
|
||||
// A `rid` is a dotted permission id such as "persons.list.client.omde". The
|
||||
// frontend expands each stored rid into its ancestors at runtime via
|
||||
// checkParent() — see apps/admin-panel/src/stores/users.ts. Only the 58 leaf
|
||||
// rids the routers actually gate are seeded; ancestors are derived, not stored.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model UserType {
|
||||
/// Legacy `permission.ut` / `users.ut` — an integer, kept as the PK so seeded
|
||||
/// rows keep their legacy identity rather than getting new cuids.
|
||||
id Int @id
|
||||
title String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
permissions Permission[]
|
||||
adminUsers AdminUser[]
|
||||
|
||||
@@map("user_type")
|
||||
}
|
||||
|
||||
model Permission {
|
||||
/// Dotted permission id, e.g. "order.list", "persons.list.client.omde".
|
||||
rid String
|
||||
userTypeId Int
|
||||
userType UserType @relation(fields: [userTypeId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Legacy's composite primary key (rid, ut), preserved exactly.
|
||||
@@id([rid, userTypeId])
|
||||
@@index([userTypeId])
|
||||
@@map("permission")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Product comments / reviews — legacy `comment` table (App\Models\Comment),
|
||||
// moderated from views/customer/Comments/Comments.vue.
|
||||
//
|
||||
// Legacy fillable: product_id, comment, reply_to, sts, customer_id
|
||||
// Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده']
|
||||
// which is represented here as an enum rather than a magic integer.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum CommentStatus {
|
||||
PENDING // 1 — در انتظار تایید
|
||||
APPROVED // 2 — تایید شده
|
||||
REJECTED // 3 — رد شده
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
body String
|
||||
status CommentStatus @default(PENDING)
|
||||
|
||||
/// Legacy `reply_to`: a staff reply threads under the comment it answers.
|
||||
replyToId String?
|
||||
replyTo Comment? @relation("CommentReplies", fields: [replyToId], references: [id])
|
||||
replies Comment[] @relation("CommentReplies")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([productId])
|
||||
@@index([customerId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Settings — replaces legacy `config_static`, a key/value table the admin
|
||||
// panel read and wrote through endpoints like /wallet/config and
|
||||
// /const/__list__ut. Values are stored as JSON so a setting can hold a scalar,
|
||||
// an object, or the dynamic option lists legacy kept under `__list__*` keys.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Setting {
|
||||
key String @id
|
||||
value Json
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Promotions / discounts — legacy views/promotion/*, served by
|
||||
// PromotionController (`product/variant/promotion`). The redesigned schema had
|
||||
// no promotion concept at all; this models what the legacy forms actually
|
||||
// collect.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Legacy Promotion.type — what the discount is scoped to.
|
||||
enum PromotionScope {
|
||||
PRODUCT // 1 محصولات
|
||||
CATEGORY // 2 دسته بندی
|
||||
CUSTOMER // 3 مشتری
|
||||
BRAND // 4 برند
|
||||
}
|
||||
|
||||
/// Legacy Promotion.use_only — how often a customer may redeem it.
|
||||
enum PromotionUsage {
|
||||
ONCE // 1 فقط یکبار
|
||||
UNTIL_END_DATE // 2 تا تاریخ پایان
|
||||
}
|
||||
|
||||
model Promotion {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
scope PromotionScope @default(PRODUCT)
|
||||
usage PromotionUsage @default(ONCE)
|
||||
/// Percentage off, 0-100 (legacy discount_percent).
|
||||
discountPercent Decimal @db.Decimal(6, 3)
|
||||
isActive Boolean @default(true)
|
||||
/// Which storefront it shows on, mirroring Product.showType (legacy platform).
|
||||
platform Int @default(3)
|
||||
|
||||
startsAt DateTime?
|
||||
endsAt DateTime?
|
||||
|
||||
/// Exactly one of these is populated, per `scope`.
|
||||
productId String?
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
customerId String?
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
brandId String?
|
||||
brand Brand? @relation(fields: [brandId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([scope, isActive])
|
||||
}
|
||||
|
||||
/// Legacy "ignore" table (views/offersIgnore) — specific products, categories,
|
||||
/// customers or brands excluded from the automatic discount engine.
|
||||
/// Legacy posts { type, item_id[] }: "type" reuses the promotion scope codes
|
||||
/// (1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند) and "itemId" is the excluded row.
|
||||
model PromotionExclusion {
|
||||
id String @id @default(cuid())
|
||||
type Int
|
||||
itemId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([type, itemId])
|
||||
@@index([type])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Branch stock allocation — legacy views/invoice/* ("فاکتور انتقال"), served by
|
||||
// BranchController as `branch/product`. Despite the screen being called an
|
||||
// invoice, it is not a sales document: it records which products head office
|
||||
// allocated to a branch, and who allocated them.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model BranchAllocation {
|
||||
id String @id @default(cuid())
|
||||
branchId String
|
||||
branch Branch @relation(fields: [branchId], references: [id])
|
||||
|
||||
/// Legacy `allocator_user` — the admin who filed the allocation.
|
||||
allocatorAdminUserId String?
|
||||
allocatorAdminUser AdminUser? @relation("AllocationAuthor", fields: [allocatorAdminUserId], references: [id])
|
||||
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
items BranchAllocationItem[]
|
||||
|
||||
@@index([branchId])
|
||||
}
|
||||
|
||||
model BranchAllocationItem {
|
||||
id String @id @default(cuid())
|
||||
allocationId String
|
||||
allocation BranchAllocation @relation(fields: [allocationId], references: [id], onDelete: Cascade)
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
/// Weight snapshot at allocation time, so later catalog edits do not rewrite history.
|
||||
weightGrams Decimal? @db.Decimal(14, 4)
|
||||
|
||||
@@unique([allocationId, productId])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Messaging + audit — legacy views/sms, views/notify, views/newsletters and
|
||||
// views/statics. None of these had a model in the redesigned schema.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Who an SMS or notification was aimed at. Legacy's sms/create offered these
|
||||
/// audiences, each gated by its own rid (sms.create.all, .omde, .single, ...).
|
||||
enum MessageAudience {
|
||||
SINGLE_NUMBER // یک شماره
|
||||
MULTIPLE_NUMBERS // چند شماره
|
||||
ALL_CUSTOMERS // همه
|
||||
WHOLESALE_CUSTOMERS // عمده
|
||||
RETAIL_CUSTOMERS // تک
|
||||
SALES_AGENTS // کارشناسان فروش
|
||||
}
|
||||
|
||||
model SmsMessage {
|
||||
id String @id @default(cuid())
|
||||
audience MessageAudience
|
||||
/// Comma-separated recipients for the SINGLE/MULTIPLE audiences.
|
||||
recipients String?
|
||||
body String
|
||||
/// How many numbers the send actually resolved to.
|
||||
sentCount Int @default(0)
|
||||
|
||||
sentByAdminUserId String?
|
||||
sentByAdminUser AdminUser? @relation("SmsAuthor", fields: [sentByAdminUserId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
body String
|
||||
audience MessageAudience @default(ALL_CUSTOMERS)
|
||||
|
||||
sentByAdminUserId String?
|
||||
sentByAdminUser AdminUser? @relation("NotificationAuthor", fields: [sentByAdminUserId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
recipients NotificationRecipient[]
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// Per-customer delivery + read state — legacy `GET notify/read/{id}`.
|
||||
model NotificationRecipient {
|
||||
id String @id @default(cuid())
|
||||
notificationId String
|
||||
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
readAt DateTime?
|
||||
|
||||
@@unique([notificationId, customerId])
|
||||
@@index([customerId, readAt])
|
||||
}
|
||||
|
||||
/// Legacy views/newsletters — the mailing-list signups collected by the site.
|
||||
model NewsletterSubscriber {
|
||||
id String @id @default(cuid())
|
||||
email String?
|
||||
mobile String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([email])
|
||||
@@unique([mobile])
|
||||
}
|
||||
|
||||
/// Legacy views/statics — the customer activity log the "آمار" screen reads
|
||||
/// (`log/list/customer`, `log/ago/{customerId}`).
|
||||
model CustomerActivityLog {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
/// Free-form action key, e.g. 'login', 'order.placed', 'profile.updated'.
|
||||
action String
|
||||
detail String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([customerId, createdAt])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Uploads — replaces the legacy MyUpload / SingleUploadInput widgets, which
|
||||
// POSTed to a file endpoint and stored the returned URL on the owning record.
|
||||
// Ports of those forms currently take a URL by hand; this gives them a real
|
||||
// destination to upload to.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model UploadedFile {
|
||||
id String @id @default(cuid())
|
||||
/// Public URL the stored file is served from.
|
||||
url String
|
||||
originalName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
/// Free-form tag for what the file belongs to, e.g. 'product', 'category',
|
||||
/// 'customer.nationalId'. Kept loose so a new form needs no schema change.
|
||||
purpose String?
|
||||
|
||||
uploadedByAdminUserId String?
|
||||
uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
customerAvatars Customer[] @relation("CustomerAvatar")
|
||||
|
||||
@@index([purpose])
|
||||
}
|
||||
|
||||
+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