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:
@@ -0,0 +1,134 @@
|
||||
// One-off data fix: the 5 hand-authored demo products (product-classic-ring,
|
||||
// product-woven-bracelet, product-pendant-necklace, product-hoop-earrings,
|
||||
// product-full-coin) each had exactly one ProductImage row pointing at
|
||||
// /img/no-items.png — a missing-image placeholder, not a real photo. This
|
||||
// replaces those with a real, varied set drawn from the 15 jewelry photos the
|
||||
// user pointed at (originally in "Downloads/New folder", already copied into
|
||||
// storage/uploads/ under their original filenames — confirmed present before
|
||||
// this script was written).
|
||||
//
|
||||
// Distribution, mirroring the one product in this DB that already has a full,
|
||||
// correctly-roled image set (`شسی`, id cmu4ia4iq0008s4zhwrz6wy3z):
|
||||
// - exactly one FEATURED (تصویر شاخص وب)
|
||||
// - exactly one FEATURED_MOBILE (تصویر شاخص موبایل)
|
||||
// - 2–3 GALLERY / GALLERY_MOBILE, split across both roles
|
||||
//
|
||||
// CHECKED, not assumed: CatalogService.listProducts() (apps/api/src/modules/
|
||||
// catalog/catalog.service.ts) returns every ProductImage row unfiltered —
|
||||
// there is no role-based split on the read side today. So on the user-app
|
||||
// side ALL of these rows end up in ProductSummary.images and all of them are
|
||||
// swipeable in the gallery/preview — the FEATURED/FEATURED_MOBILE role still
|
||||
// matters (it is what the admin panel and any future "hero image" logic reads
|
||||
// by role), but right now it does not narrow what the shopper-facing gallery
|
||||
// shows. `sortOrder` is set to the creation index below, which is what
|
||||
// actually determines swipe order today, since nothing filters by role.
|
||||
//
|
||||
// Assignment across the 5 products is a random, non-repeating draw per
|
||||
// product from the 15-photo pool (Fisher-Yates, reseedable via IMG_SEED for a
|
||||
// reproducible run) — the same photo can end up on more than one product
|
||||
// across different products (15 photos split ~4 ways per product exceeds the
|
||||
// pool), which is fine: this is demo data standing in for real per-product
|
||||
// photography, not a claim that these are 5 distinct real items.
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// The 15 files confirmed present in storage/uploads/ under their original
|
||||
// names before this script ran.
|
||||
const POOL = [
|
||||
'152_5e1435ca-05a5-4146-a4ea-89726650c29c.webp',
|
||||
'59_0661f668-5d1d-4c05-87fd-50fd65cd8383.webp',
|
||||
'71085964.png',
|
||||
'79A202-1201_01.webp',
|
||||
'Brooches-Vintage-Jewelry-full-1-322_60.60-9ed8e81b.webp',
|
||||
'MB116662_Front_1080p_f364da1d-6019-4ca4-8dd8-a323387634c7.webp',
|
||||
'TEMP_360x360.webp',
|
||||
'channel_setting_480x480.webp',
|
||||
'engagement-ring-in-18k-gold-with-diamonds-nr532-white-18k-semi-white-205224.webp',
|
||||
'finished-jewelry.webp',
|
||||
'kisspng-earring-gold-jewellery-ring-new-22ct-yellow-gold-ring-armari-45-sku27555-5d0796cb466539.4977043815607784432884-768x768.png',
|
||||
'pl-background-free-product-photography-jewelery-3.webp',
|
||||
'round-cut-engagement-ring-in-18k-gold-with-diamonds-lr3011-2t-18k-x-2t-964060.webp',
|
||||
'td4-aam-n0_dd28f69d-9e3c-490d-be37-1ba074737992.webp',
|
||||
'w=640,h=640,fit=crop.avif',
|
||||
];
|
||||
|
||||
const PRODUCT_IDS = [
|
||||
'product-classic-ring',
|
||||
'product-woven-bracelet',
|
||||
'product-pendant-necklace',
|
||||
'product-hoop-earrings',
|
||||
'product-full-coin',
|
||||
];
|
||||
|
||||
// Simple seeded PRNG (mulberry32) so a re-run with the same IMG_SEED is
|
||||
// reproducible — useful for re-checking a specific assignment without the
|
||||
// draw changing under you.
|
||||
function mulberry32(seed) {
|
||||
return function () {
|
||||
seed |= 0;
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
const seed = Number(process.env.IMG_SEED) || Date.now();
|
||||
const rand = mulberry32(seed);
|
||||
|
||||
function shuffled(arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`seed=${seed}`);
|
||||
|
||||
for (const productId of PRODUCT_IDS) {
|
||||
const product = await prisma.product.findUnique({ where: { id: productId } });
|
||||
if (!product) {
|
||||
console.log(`skip ${productId}: not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3–5 photos per product, drawn without replacement from the pool.
|
||||
const count = 3 + Math.floor(rand() * 3); // 3, 4, or 5
|
||||
const picks = shuffled(POOL).slice(0, count);
|
||||
|
||||
// Role plan: first pick is FEATURED, second is FEATURED_MOBILE, the rest
|
||||
// alternate GALLERY / GALLERY_MOBILE — same shape as the شسی product's
|
||||
// existing 8-image set, just proportioned to a smaller pick count.
|
||||
const roles = ['FEATURED', 'FEATURED_MOBILE'];
|
||||
for (let i = 2; i < picks.length; i++) {
|
||||
roles.push(i % 2 === 0 ? 'GALLERY' : 'GALLERY_MOBILE');
|
||||
}
|
||||
|
||||
// Replace whatever this product currently has (the single no-items.png
|
||||
// placeholder row) rather than appending alongside it.
|
||||
await prisma.productImage.deleteMany({ where: { productId } });
|
||||
|
||||
for (let i = 0; i < picks.length; i++) {
|
||||
await prisma.productImage.create({
|
||||
data: {
|
||||
id: `${productId}-img-${i + 1}`,
|
||||
productId,
|
||||
url: `/uploads/${picks[i]}`,
|
||||
sortOrder: i,
|
||||
role: roles[i],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`${productId}: ${picks.length} images -> ${picks.map((p, i) => `${roles[i]}:${p}`).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,59 @@
|
||||
// Generates distinct SVG placeholder images per product category, so the 200
|
||||
// mock products render varied artwork instead of one shared no-items.png icon.
|
||||
// Not photography — an honest stand-in, styled to the app's own gold/cream
|
||||
// palette, with a distinct glyph + background tint per category so the grid
|
||||
// reads as varied rather than one repeated tile.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OUT_DIR = path.join(__dirname, '../../user-app/public/img/products');
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
// category slug -> [glyph path (viewBox 0 0 64 64), accent color]
|
||||
const GLYPHS = {
|
||||
rings: {
|
||||
accent: '#B08D57',
|
||||
path: '<circle cx="32" cy="40" r="14" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="20" r="6" fill="currentColor"/>',
|
||||
},
|
||||
bracelets: {
|
||||
accent: '#8D6A46',
|
||||
path: '<ellipse cx="32" cy="32" rx="22" ry="14" fill="none" stroke="currentColor" stroke-width="4"/><ellipse cx="32" cy="32" rx="14" ry="8" fill="none" stroke="currentColor" stroke-width="2" opacity="0.5"/>',
|
||||
},
|
||||
necklaces: {
|
||||
accent: '#C9A24B',
|
||||
path: '<path d="M14 14 C14 34 24 44 32 44 C40 44 50 34 50 14" fill="none" stroke="currentColor" stroke-width="3"/><path d="M32 44 L32 52 L26 58 L38 58 Z" fill="currentColor"/>',
|
||||
},
|
||||
earrings: {
|
||||
accent: '#A67C52',
|
||||
path: '<circle cx="32" cy="16" r="5" fill="currentColor"/><path d="M32 21 L32 34" stroke="currentColor" stroke-width="3"/><circle cx="32" cy="42" r="10" fill="none" stroke="currentColor" stroke-width="3"/>',
|
||||
},
|
||||
coins: {
|
||||
accent: '#D4AF37',
|
||||
path: '<circle cx="32" cy="32" r="22" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="32" r="14" fill="none" stroke="currentColor" stroke-width="2" opacity="0.6"/><text x="32" y="38" font-size="14" text-anchor="middle" fill="currentColor" font-family="sans-serif">Au</text>',
|
||||
},
|
||||
};
|
||||
|
||||
// A handful of warm background tints so 200 tiles don't look identical even
|
||||
// within one category.
|
||||
const BG_TINTS = ['#F6F3EF', '#F3EADC', '#FAF5EC', '#F0E6D6', '#F8F1E6', '#EFE4D3'];
|
||||
|
||||
function svgFor(categorySlug, variantIndex) {
|
||||
const g = GLYPHS[categorySlug] || GLYPHS.rings;
|
||||
const bg = BG_TINTS[variantIndex % BG_TINTS.length];
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="${bg}"/>
|
||||
<g transform="translate(0,0)" color="${g.accent}" opacity="0.92">
|
||||
${g.path}
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
for (const slug of Object.keys(GLYPHS)) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const file = path.join(OUT_DIR, `${slug}-${i}.svg`);
|
||||
fs.writeFileSync(file, svgFor(slug, i));
|
||||
written++;
|
||||
}
|
||||
}
|
||||
console.log(`wrote ${written} placeholder SVGs to ${OUT_DIR}`);
|
||||
@@ -0,0 +1,118 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const seedPath = path.join(__dirname, 'seed.ts');
|
||||
let content = fs.readFileSync(seedPath, 'utf-8');
|
||||
|
||||
const marker = ' imageCount += 1;\n }\n\n // \u2500\u2500 Featured collection';
|
||||
if (!content.includes(marker)) {
|
||||
console.error('MARKER NOT FOUND');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const insertion = ` imageCount += 1;
|
||||
}
|
||||
|
||||
// \u2500\u2500 200 mock products for a realistic-looking catalogue \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
// These are generated, not hand-authored like the 5 above (whose ids are
|
||||
// referenced elsewhere in this seed, e.g. by orders \u2014 left untouched).
|
||||
// Images: real product photography does not exist in this project, so each
|
||||
// mock product cycles through 6 generated SVG placeholders per category
|
||||
// (apps/user-app/public/img/products/<slug>-N.svg), giving the catalogue
|
||||
// grid genuine visual variety instead of one repeated icon.
|
||||
const MOCK_CATEGORIES = [
|
||||
{ slug: 'rings', id: ringsCategory.id, nouns: ['انگشتر نگیندار', 'انگشتر سولیتر', 'انگشتر آویزدار', 'انگشتر پهن', 'انگشتر ظریف', 'انگشتر رینگی'] },
|
||||
{ slug: 'bracelets', id: braceletsCategory.id, nouns: ['دستبند زنجیری', 'دستبند پاندول', 'دستبند بافت', 'دستبند تنیس', 'دستبند پلاکدار', 'دستبند چرمی طلاکوب'] },
|
||||
{ slug: 'necklaces', id: necklacesCategory.id, nouns: ['گردنبند آویز قلب', 'گردنبند ماه و ستاره', 'گردنبند ظریف', 'گردنبند رولو', 'گردنبند صلیب', 'گردنبند اسم'] },
|
||||
{ slug: 'earrings', id: earringsCategory.id, nouns: ['گوشواره حلقهای', 'گوشواره آویز', 'گوشواره میخی', 'گوشواره پروانهای', 'گوشواره مرواریدی', 'گوشواره هندسی'] },
|
||||
{ slug: 'coins', id: coinsCategory.id, nouns: ['ربع سکه بهار آزادی', 'نیم سکه بهار آزادی', 'سکه امامی', 'سکه پارسیان', 'شمش طلای ۱۰ گرمی', 'شمش طلای ۵ گرمی'] },
|
||||
];
|
||||
const MOCK_ADJECTIVES = ['کلاسیک', 'مدرن', 'مینیمال', 'لوکس', 'دستساز', 'ظریف', 'خاص', 'شیک', 'سنتی', 'رزگلد'];
|
||||
const MOCK_BRANDS = [brandMain.id, brandArtisan.id];
|
||||
|
||||
const MOCK_PRODUCT_COUNT = 200;
|
||||
let mockImageCount = 0;
|
||||
for (let i = 0; i < MOCK_PRODUCT_COUNT; i++) {
|
||||
const cat = MOCK_CATEGORIES[i % MOCK_CATEGORIES.length];
|
||||
const noun = cat.nouns[i % cat.nouns.length];
|
||||
const adjective = MOCK_ADJECTIVES[i % MOCK_ADJECTIVES.length];
|
||||
const brandId = MOCK_BRANDS[i % MOCK_BRANDS.length];
|
||||
const productId = \`mock-product-\${i + 1}\`;
|
||||
const title = \`\${noun} \${adjective} \${i + 1}\`;
|
||||
// Deterministic but varied numeric fields \u2014 no randomness, so a re-seed
|
||||
// is stable and diffable.
|
||||
const baseWeight = 1.5 + ((i * 37) % 250) / 20; // ~1.5g .. ~14g
|
||||
const wagePercent = (6 + ((i * 13) % 12)).toFixed(1); // 6.0% .. 17.9%
|
||||
const qty = (i * 7) % 15; // 0..14, so some are genuinely out of stock
|
||||
const isPreOrder = i % 9 === 0;
|
||||
|
||||
const product = await prisma.product.upsert({
|
||||
where: { id: productId },
|
||||
update: {},
|
||||
create: {
|
||||
id: productId,
|
||||
title,
|
||||
description: \`\${noun} با طراحی \${adjective}، مناسب استفاده روزمره یا هدیه.\`,
|
||||
categoryId: cat.id,
|
||||
brandId,
|
||||
productCode: \`P-\${String(i + 1).padStart(4, '0')}\`,
|
||||
},
|
||||
});
|
||||
|
||||
const variantId = \`\${productId}-variant-1\`;
|
||||
await prisma.productVariant.upsert({
|
||||
where: { id: variantId },
|
||||
update: {},
|
||||
create: {
|
||||
id: variantId,
|
||||
productId: product.id,
|
||||
title: 'استاندارد',
|
||||
sku: \`MOCK-\${String(i + 1).padStart(4, '0')}\`,
|
||||
rashWeightGrams: baseWeight.toFixed(3),
|
||||
defaultWagePercent: wagePercent,
|
||||
qty,
|
||||
isPreOrder,
|
||||
},
|
||||
});
|
||||
|
||||
// A second variant on ~1 in 4 products, so the "\u062a\u0646\u0648\u0639 \u0648\u0632\u0646\u06cc" chip row has
|
||||
// something real to show on more than the 5 hand-authored products.
|
||||
if (i % 4 === 0) {
|
||||
const variant2Id = \`\${productId}-variant-2\`;
|
||||
await prisma.productVariant.upsert({
|
||||
where: { id: variant2Id },
|
||||
update: {},
|
||||
create: {
|
||||
id: variant2Id,
|
||||
productId: product.id,
|
||||
title: 'سایز بزرگ',
|
||||
sku: \`MOCK-\${String(i + 1).padStart(4, '0')}-L\`,
|
||||
rashWeightGrams: (baseWeight * 1.35).toFixed(3),
|
||||
defaultWagePercent: wagePercent,
|
||||
qty: (qty + 3) % 15,
|
||||
isPreOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imgIndex = i % 6;
|
||||
const imageUrl = \`/img/products/\${cat.slug}-\${imgIndex}.svg\`;
|
||||
await prisma.productImage.upsert({
|
||||
where: { id: \`\${productId}-img-1\` },
|
||||
update: { url: imageUrl },
|
||||
create: {
|
||||
id: \`\${productId}-img-1\`,
|
||||
productId: product.id,
|
||||
url: imageUrl,
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
mockImageCount += 1;
|
||||
}
|
||||
imageCount += mockImageCount;
|
||||
|
||||
// \u2500\u2500 Featured collection`;
|
||||
|
||||
content = content.replace(marker, insertion);
|
||||
fs.writeFileSync(seedPath, content, 'utf-8');
|
||||
console.log('inserted mock product generator into seed.ts');
|
||||
@@ -0,0 +1,396 @@
|
||||
-- Additive migration: 0001_init created the original core tables.
|
||||
-- The admin-panel port later added columns/enums/tables to schema.prisma
|
||||
-- without a SQL migration, which produced P2022 on production
|
||||
-- ("The column Product.productCode does not exist").
|
||||
--
|
||||
-- Statements are idempotent so a database that was already brought up to date
|
||||
-- with `prisma db push` still records this file as applied.
|
||||
|
||||
-- ── Enums ────────────────────────────────────────────────────────────────
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "CustomerKind" AS ENUM ('SALES_AGENT', 'APP_SHOPKEEPER', 'WALK_IN_SHOPKEEPER', 'BANKER', 'WORKSHOP_SUPERVISOR');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "OrderChannel" AS ENUM ('RETAIL', 'WHOLESALE');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "CommentStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "PromotionScope" AS ENUM ('PRODUCT', 'CATEGORY', 'CUSTOMER', 'BRAND');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "PromotionUsage" AS ENUM ('ONCE', 'UNTIL_END_DATE');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "MessageAudience" AS ENUM ('SINGLE_NUMBER', 'MULTIPLE_NUMBERS', 'ALL_CUSTOMERS', 'WHOLESALE_CUSTOMERS', 'RETAIL_CUSTOMERS', 'SALES_AGENTS');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- ── Columns on existing tables ───────────────────────────────────────────
|
||||
|
||||
ALTER TABLE "City" ADD COLUMN IF NOT EXISTS "percent" DECIMAL(6,3);
|
||||
ALTER TABLE "City" ADD COLUMN IF NOT EXISTS "percentAux" DECIMAL(6,3);
|
||||
|
||||
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "userTypeId" INTEGER;
|
||||
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "kind" "CustomerKind" NOT NULL DEFAULT 'APP_SHOPKEEPER';
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "phone" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "address" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "storeName" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "pasazhName" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "zone" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "memberCode" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "avatarFileId" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "canPlaceOrders" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "isArchived" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "Branch" ADD COLUMN IF NOT EXISTS "address" TEXT;
|
||||
ALTER TABLE "Branch" ADD COLUMN IF NOT EXISTS "phone" TEXT;
|
||||
ALTER TABLE "Branch" ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "Workshop" ADD COLUMN IF NOT EXISTS "address" TEXT;
|
||||
ALTER TABLE "Workshop" ADD COLUMN IF NOT EXISTS "phone" TEXT;
|
||||
ALTER TABLE "Workshop" ADD COLUMN IF NOT EXISTS "supervisorId" TEXT;
|
||||
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "type" INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "imageUrl" TEXT;
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "iconUrl" TEXT;
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "metaTag" TEXT;
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "description" TEXT;
|
||||
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "productCode" TEXT;
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "showType" INTEGER NOT NULL DEFAULT 3;
|
||||
|
||||
ALTER TABLE "ProductVariant" ADD COLUMN IF NOT EXISTS "qty" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "ProductVariant" ADD COLUMN IF NOT EXISTS "isPreOrder" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "channel" "OrderChannel" NOT NULL DEFAULT 'RETAIL';
|
||||
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "labName" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "hallmark" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "accountName" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "bankName" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "transferTo" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "originalValue" DECIMAL(14,4);
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "priceRial" DECIMAL(20,2);
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "receivedAt" TIMESTAMP(3);
|
||||
|
||||
-- ── New tables ───────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user_type" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_type_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "permission" (
|
||||
"rid" TEXT NOT NULL,
|
||||
"userTypeId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "permission_pkey" PRIMARY KEY ("rid","userTypeId")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Comment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"status" "CommentStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"replyToId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Setting" (
|
||||
"key" TEXT NOT NULL,
|
||||
"value" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Promotion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"scope" "PromotionScope" NOT NULL DEFAULT 'PRODUCT',
|
||||
"usage" "PromotionUsage" NOT NULL DEFAULT 'ONCE',
|
||||
"discountPercent" DECIMAL(6,3) NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"platform" INTEGER NOT NULL DEFAULT 3,
|
||||
"startsAt" TIMESTAMP(3),
|
||||
"endsAt" TIMESTAMP(3),
|
||||
"productId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"customerId" TEXT,
|
||||
"brandId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Promotion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "PromotionExclusion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" INTEGER NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PromotionExclusion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "BranchAllocation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"branchId" TEXT NOT NULL,
|
||||
"allocatorAdminUserId" TEXT,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "BranchAllocation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "BranchAllocationItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"allocationId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"weightGrams" DECIMAL(14,4),
|
||||
|
||||
CONSTRAINT "BranchAllocationItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "SmsMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"audience" "MessageAudience" NOT NULL,
|
||||
"recipients" TEXT,
|
||||
"body" TEXT NOT NULL,
|
||||
"sentCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"sentByAdminUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SmsMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Notification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"audience" "MessageAudience" NOT NULL DEFAULT 'ALL_CUSTOMERS',
|
||||
"sentByAdminUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "NotificationRecipient" (
|
||||
"id" TEXT NOT NULL,
|
||||
"notificationId" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "NotificationRecipient_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "NewsletterSubscriber" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"mobile" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "NewsletterSubscriber_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "CustomerActivityLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"detail" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CustomerActivityLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "UploadedFile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"originalName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"purpose" TEXT,
|
||||
"uploadedByAdminUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UploadedFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- ── Indexes ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "AdminUser_userTypeId_idx" ON "AdminUser"("userTypeId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "Product_productCode_key" ON "Product"("productCode");
|
||||
CREATE INDEX IF NOT EXISTS "permission_userTypeId_idx" ON "permission"("userTypeId");
|
||||
CREATE INDEX IF NOT EXISTS "Comment_productId_idx" ON "Comment"("productId");
|
||||
CREATE INDEX IF NOT EXISTS "Comment_customerId_idx" ON "Comment"("customerId");
|
||||
CREATE INDEX IF NOT EXISTS "Comment_status_idx" ON "Comment"("status");
|
||||
CREATE INDEX IF NOT EXISTS "Promotion_scope_isActive_idx" ON "Promotion"("scope", "isActive");
|
||||
CREATE INDEX IF NOT EXISTS "PromotionExclusion_type_idx" ON "PromotionExclusion"("type");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PromotionExclusion_type_itemId_key" ON "PromotionExclusion"("type", "itemId");
|
||||
CREATE INDEX IF NOT EXISTS "BranchAllocation_branchId_idx" ON "BranchAllocation"("branchId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "BranchAllocationItem_allocationId_productId_key" ON "BranchAllocationItem"("allocationId", "productId");
|
||||
CREATE INDEX IF NOT EXISTS "SmsMessage_createdAt_idx" ON "SmsMessage"("createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "Notification_createdAt_idx" ON "Notification"("createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "NotificationRecipient_customerId_readAt_idx" ON "NotificationRecipient"("customerId", "readAt");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "NotificationRecipient_notificationId_customerId_key" ON "NotificationRecipient"("notificationId", "customerId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "NewsletterSubscriber_email_key" ON "NewsletterSubscriber"("email");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "NewsletterSubscriber_mobile_key" ON "NewsletterSubscriber"("mobile");
|
||||
CREATE INDEX IF NOT EXISTS "CustomerActivityLog_customerId_createdAt_idx" ON "CustomerActivityLog"("customerId", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "UploadedFile_purpose_idx" ON "UploadedFile"("purpose");
|
||||
|
||||
-- ── Foreign keys added after 0001_init ───────────────────────────────────
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_userTypeId_fkey" FOREIGN KEY ("userTypeId") REFERENCES "user_type"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_avatarFileId_fkey" FOREIGN KEY ("avatarFileId") REFERENCES "UploadedFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Workshop" ADD CONSTRAINT "Workshop_supervisorId_fkey" FOREIGN KEY ("supervisorId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "permission" ADD CONSTRAINT "permission_userTypeId_fkey" FOREIGN KEY ("userTypeId") REFERENCES "user_type"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_replyToId_fkey" FOREIGN KEY ("replyToId") REFERENCES "Comment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Promotion" ADD CONSTRAINT "Promotion_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Promotion" ADD CONSTRAINT "Promotion_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Promotion" ADD CONSTRAINT "Promotion_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Promotion" ADD CONSTRAINT "Promotion_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "BranchAllocation" ADD CONSTRAINT "BranchAllocation_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "BranchAllocation" ADD CONSTRAINT "BranchAllocation_allocatorAdminUserId_fkey" FOREIGN KEY ("allocatorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "BranchAllocationItem" ADD CONSTRAINT "BranchAllocationItem_allocationId_fkey" FOREIGN KEY ("allocationId") REFERENCES "BranchAllocation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "BranchAllocationItem" ADD CONSTRAINT "BranchAllocationItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "SmsMessage" ADD CONSTRAINT "SmsMessage_sentByAdminUserId_fkey" FOREIGN KEY ("sentByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_sentByAdminUserId_fkey" FOREIGN KEY ("sentByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "NotificationRecipient" ADD CONSTRAINT "NotificationRecipient_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "NotificationRecipient" ADD CONSTRAINT "NotificationRecipient_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "CustomerActivityLog" ADD CONSTRAINT "CustomerActivityLog_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "UploadedFile" ADD CONSTRAINT "UploadedFile_uploadedByAdminUserId_fkey" FOREIGN KEY ("uploadedByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -0,0 +1,430 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AdminRole" AS ENUM ('SUPER_ADMIN', 'SYSTEM_APPROVER', 'SALES_EXPERT', 'WORKSHOP_SUPERVISOR', 'ACCOUNTANT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VerificationStep" AS ENUM ('UNVERIFIED', 'STEP_ONE_SUBMITTED', 'STEP_TWO_SUBMITTED', 'VERIFIED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OrderStatus" AS ENUM ('SUSPENDED', 'PLACED', 'ADMIN_APPROVED', 'SOURCING', 'PREPARING', 'DELIVERED_TO_AGENT', 'DELIVERED_TO_CUSTOMER', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PreOrderStatus" AS ENUM ('PLACED', 'ADMIN_APPROVED', 'WORKSHOP_APPROVED', 'READY_TO_SHIP', 'DELIVERED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LedgerCurrency" AS ENUM ('GOLD_GRAM', 'RIAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LedgerDirection" AS ENUM ('DEBIT', 'CREDIT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LedgerSourceType" AS ENUM ('ORDER_STOCK', 'ORDER_PRE_ORDER', 'REPAYMENT_MELTED_GOLD', 'REPAYMENT_TRANSFER', 'REPAYMENT_CHEQUE', 'REPAYMENT_CASH', 'WALLET_BONUS', 'ADJUSTMENT');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "City" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"province" TEXT,
|
||||
|
||||
CONSTRAINT "City_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AdminUser" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"role" "AdminRole" NOT NULL,
|
||||
"branchId" TEXT,
|
||||
"wagePercent" DECIMAL(6,3),
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Customer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"nationalCode" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"cityId" TEXT,
|
||||
"referredByAgentId" TEXT,
|
||||
"goldCreditLimitGrams" DECIMAL(14,4) NOT NULL DEFAULT 0,
|
||||
"goldDebtGrams" DECIMAL(14,4) NOT NULL DEFAULT 0,
|
||||
"fiatDebtRial" DECIMAL(20,2) NOT NULL DEFAULT 0,
|
||||
"verificationStep" "VerificationStep" NOT NULL DEFAULT 'UNVERIFIED',
|
||||
"nationalIdImageUrl" TEXT,
|
||||
"businessLicenseUrl" TEXT,
|
||||
"shopImageUrl" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CustomerAddress" (
|
||||
"id" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"cityId" TEXT,
|
||||
"addressLine" TEXT NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "CustomerAddress_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Branch" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"cityId" TEXT,
|
||||
|
||||
CONSTRAINT "Branch_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Workshop" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "Workshop_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Brand" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Brand_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Collection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "Collection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CollectionProduct" (
|
||||
"collectionId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "CollectionProduct_pkey" PRIMARY KEY ("collectionId","productId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"brandId" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductVariant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"sku" TEXT,
|
||||
"rashWeightGrams" DECIMAL(14,4) NOT NULL,
|
||||
"defaultWagePercent" DECIMAL(6,3) NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Order" (
|
||||
"id" TEXT NOT NULL,
|
||||
"orderNumber" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"branchId" TEXT,
|
||||
"status" "OrderStatus" NOT NULL DEFAULT 'PLACED',
|
||||
"totalGoldWeightGrams" DECIMAL(14,4) NOT NULL,
|
||||
"wageWeightGrams" DECIMAL(14,4) NOT NULL,
|
||||
"flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0,
|
||||
"isSuspendedOverCreditLimit" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrderItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"orderId" TEXT NOT NULL,
|
||||
"productVariantId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"rashWeightGramsSnapshot" DECIMAL(14,4) NOT NULL,
|
||||
"wagePercentSnapshot" DECIMAL(6,3) NOT NULL,
|
||||
"discountPercentSnapshot" DECIMAL(6,3) NOT NULL DEFAULT 0,
|
||||
"computedWeightGrams" DECIMAL(14,4) NOT NULL,
|
||||
"flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrderStatusEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"orderId" TEXT NOT NULL,
|
||||
"fromStatus" "OrderStatus",
|
||||
"toStatus" "OrderStatus" NOT NULL,
|
||||
"note" TEXT,
|
||||
"actorAdminUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "OrderStatusEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PreOrder" (
|
||||
"id" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"cityId" TEXT,
|
||||
"workshopId" TEXT,
|
||||
"status" "PreOrderStatus" NOT NULL DEFAULT 'PLACED',
|
||||
"designImageUrl" TEXT,
|
||||
"colorName" TEXT,
|
||||
"sizeSpec" TEXT,
|
||||
"stoneColor" TEXT,
|
||||
"minaColor" TEXT,
|
||||
"estimatedWeightGrams" DECIMAL(14,4),
|
||||
"estimatedPriceRial" DECIMAL(20,2),
|
||||
"description" TEXT,
|
||||
"convertedOrderId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PreOrder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PreOrderStatusEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"preOrderId" TEXT NOT NULL,
|
||||
"changes" JSONB NOT NULL,
|
||||
"actorAdminUserId" TEXT,
|
||||
"ipAddress" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PreOrderStatusEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LedgerEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"currency" "LedgerCurrency" NOT NULL,
|
||||
"direction" "LedgerDirection" NOT NULL,
|
||||
"sourceType" "LedgerSourceType" NOT NULL,
|
||||
"amount" DECIMAL(20,4) NOT NULL,
|
||||
"karatPerMille" INTEGER,
|
||||
"pricePerGramRialAtEntry" DECIMAL(20,2),
|
||||
"orderId" TEXT,
|
||||
"confirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"confirmedAt" TIMESTAMP(3),
|
||||
"postedByAdminUserId" TEXT,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LedgerEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GoldPriceQuote" (
|
||||
"id" TEXT NOT NULL,
|
||||
"karatPerMille" INTEGER NOT NULL,
|
||||
"pricePerGramRial" DECIMAL(20,2) NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"quotedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GoldPriceQuote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WalletBonus" (
|
||||
"id" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"orderId" TEXT,
|
||||
"goldGrams" DECIMAL(14,4) NOT NULL,
|
||||
"grantedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "WalletBonus_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AdminUser_mobile_key" ON "AdminUser"("mobile");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminUser_role_idx" ON "AdminUser"("role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Customer_mobile_key" ON "Customer"("mobile");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Customer_nationalCode_key" ON "Customer"("nationalCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Customer_referredByAgentId_idx" ON "Customer"("referredByAgentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductVariant_sku_key" ON "ProductVariant"("sku");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Order_orderNumber_key" ON "Order"("orderNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Order_customerId_idx" ON "Order"("customerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Order_status_idx" ON "Order"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrderStatusEvent_orderId_idx" ON "OrderStatusEvent"("orderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PreOrder_convertedOrderId_key" ON "PreOrder"("convertedOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PreOrder_customerId_idx" ON "PreOrder"("customerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PreOrderStatusEvent_preOrderId_idx" ON "PreOrderStatusEvent"("preOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LedgerEntry_customerId_currency_idx" ON "LedgerEntry"("customerId", "currency");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LedgerEntry_orderId_idx" ON "LedgerEntry"("orderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GoldPriceQuote_karatPerMille_quotedAt_idx" ON "GoldPriceQuote"("karatPerMille", "quotedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WalletBonus_customerId_isActive_idx" ON "WalletBonus"("customerId", "isActive");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_referredByAgentId_fkey" FOREIGN KEY ("referredByAgentId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Branch" ADD CONSTRAINT "Branch_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productVariantId_fkey" FOREIGN KEY ("productVariantId") REFERENCES "ProductVariant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_convertedOrderId_fkey" FOREIGN KEY ("convertedOrderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_preOrderId_fkey" FOREIGN KEY ("preOrderId") REFERENCES "PreOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_postedByAdminUserId_fkey" FOREIGN KEY ("postedByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WalletBonus" ADD CONSTRAINT "WalletBonus_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,241 @@
|
||||
-- Sync schema drift (admin/product/customer fields + new tables).
|
||||
-- NOTE: Do NOT drop "_hedenagold_migrations" (or legacy "_goldbank_migrations") — owned by the runtime
|
||||
-- migration runner, not by Prisma schema. Prisma migrate diff wrongly included it.
|
||||
-- Statements below are idempotent so a partially-applied previous attempt can finish.
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN CREATE TYPE "CustomerKind" AS ENUM ('SALES_AGENT', 'APP_SHOPKEEPER', 'WALK_IN_SHOPKEEPER', 'BANKER', 'WORKSHOP_SUPERVISOR'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE "OrderChannel" AS ENUM ('RETAIL', 'WHOLESALE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE "CommentStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE "PromotionScope" AS ENUM ('PRODUCT', 'CATEGORY', 'CUSTOMER', 'BRAND'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE "PromotionUsage" AS ENUM ('ONCE', 'UNTIL_END_DATE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE "MessageAudience" AS ENUM ('SINGLE_NUMBER', 'MULTIPLE_NUMBERS', 'ALL_CUSTOMERS', 'WHOLESALE_CUSTOMERS', 'RETAIL_CUSTOMERS', 'SALES_AGENTS'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "userTypeId" INTEGER;
|
||||
|
||||
ALTER TABLE "Branch" ADD COLUMN IF NOT EXISTS "address" TEXT;
|
||||
ALTER TABLE "Branch" ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "Branch" ADD COLUMN IF NOT EXISTS "phone" TEXT;
|
||||
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "description" TEXT;
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "iconUrl" TEXT;
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "imageUrl" TEXT;
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "metaTag" TEXT;
|
||||
ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "type" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
ALTER TABLE "City" ADD COLUMN IF NOT EXISTS "percent" DECIMAL(6,3);
|
||||
ALTER TABLE "City" ADD COLUMN IF NOT EXISTS "percentAux" DECIMAL(6,3);
|
||||
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "address" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "avatarFileId" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "canPlaceOrders" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "isArchived" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "kind" "CustomerKind" NOT NULL DEFAULT 'APP_SHOPKEEPER';
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "memberCode" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "pasazhName" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "phone" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "storeName" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "zone" TEXT;
|
||||
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "accountName" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "bankName" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "hallmark" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "labName" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "originalValue" DECIMAL(14,4);
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "priceRial" DECIMAL(20,2);
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "receivedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN IF NOT EXISTS "transferTo" TEXT;
|
||||
|
||||
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "channel" "OrderChannel" NOT NULL DEFAULT 'RETAIL';
|
||||
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "productCode" TEXT;
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "showType" INTEGER NOT NULL DEFAULT 3;
|
||||
|
||||
ALTER TABLE "ProductVariant" ADD COLUMN IF NOT EXISTS "isPreOrder" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "ProductVariant" ADD COLUMN IF NOT EXISTS "qty" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE "Workshop" ADD COLUMN IF NOT EXISTS "address" TEXT;
|
||||
ALTER TABLE "Workshop" ADD COLUMN IF NOT EXISTS "phone" TEXT;
|
||||
ALTER TABLE "Workshop" ADD COLUMN IF NOT EXISTS "supervisorId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "user_type" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "user_type_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "permission" (
|
||||
"rid" TEXT NOT NULL,
|
||||
"userTypeId" INTEGER NOT NULL,
|
||||
CONSTRAINT "permission_pkey" PRIMARY KEY ("rid","userTypeId")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Comment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"status" "CommentStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"replyToId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Setting" (
|
||||
"key" TEXT NOT NULL,
|
||||
"value" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Promotion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"scope" "PromotionScope" NOT NULL DEFAULT 'PRODUCT',
|
||||
"usage" "PromotionUsage" NOT NULL DEFAULT 'ONCE',
|
||||
"discountPercent" DECIMAL(6,3) NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"platform" INTEGER NOT NULL DEFAULT 3,
|
||||
"startsAt" TIMESTAMP(3),
|
||||
"endsAt" TIMESTAMP(3),
|
||||
"productId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"customerId" TEXT,
|
||||
"brandId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "Promotion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "PromotionExclusion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" INTEGER NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "PromotionExclusion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "BranchAllocation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"branchId" TEXT NOT NULL,
|
||||
"allocatorAdminUserId" TEXT,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "BranchAllocation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "BranchAllocationItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"allocationId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"weightGrams" DECIMAL(14,4),
|
||||
CONSTRAINT "BranchAllocationItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "SmsMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"audience" "MessageAudience" NOT NULL,
|
||||
"recipients" TEXT,
|
||||
"body" TEXT NOT NULL,
|
||||
"sentCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"sentByAdminUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "SmsMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Notification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"audience" "MessageAudience" NOT NULL DEFAULT 'ALL_CUSTOMERS',
|
||||
"sentByAdminUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "NotificationRecipient" (
|
||||
"id" TEXT NOT NULL,
|
||||
"notificationId" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3),
|
||||
CONSTRAINT "NotificationRecipient_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "NewsletterSubscriber" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"mobile" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "NewsletterSubscriber_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "CustomerActivityLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"detail" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "CustomerActivityLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "UploadedFile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"originalName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"purpose" TEXT,
|
||||
"uploadedByAdminUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "UploadedFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "permission_userTypeId_idx" ON "permission"("userTypeId");
|
||||
CREATE INDEX IF NOT EXISTS "Comment_productId_idx" ON "Comment"("productId");
|
||||
CREATE INDEX IF NOT EXISTS "Comment_customerId_idx" ON "Comment"("customerId");
|
||||
CREATE INDEX IF NOT EXISTS "Comment_status_idx" ON "Comment"("status");
|
||||
CREATE INDEX IF NOT EXISTS "Promotion_scope_isActive_idx" ON "Promotion"("scope", "isActive");
|
||||
CREATE INDEX IF NOT EXISTS "PromotionExclusion_type_idx" ON "PromotionExclusion"("type");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PromotionExclusion_type_itemId_key" ON "PromotionExclusion"("type", "itemId");
|
||||
CREATE INDEX IF NOT EXISTS "BranchAllocation_branchId_idx" ON "BranchAllocation"("branchId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "BranchAllocationItem_allocationId_productId_key" ON "BranchAllocationItem"("allocationId", "productId");
|
||||
CREATE INDEX IF NOT EXISTS "SmsMessage_createdAt_idx" ON "SmsMessage"("createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "Notification_createdAt_idx" ON "Notification"("createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "NotificationRecipient_customerId_readAt_idx" ON "NotificationRecipient"("customerId", "readAt");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "NotificationRecipient_notificationId_customerId_key" ON "NotificationRecipient"("notificationId", "customerId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "NewsletterSubscriber_email_key" ON "NewsletterSubscriber"("email");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "NewsletterSubscriber_mobile_key" ON "NewsletterSubscriber"("mobile");
|
||||
CREATE INDEX IF NOT EXISTS "CustomerActivityLog_customerId_createdAt_idx" ON "CustomerActivityLog"("customerId", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "UploadedFile_purpose_idx" ON "UploadedFile"("purpose");
|
||||
CREATE INDEX IF NOT EXISTS "AdminUser_userTypeId_idx" ON "AdminUser"("userTypeId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "Product_productCode_key" ON "Product"("productCode");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_userTypeId_fkey" FOREIGN KEY ("userTypeId") REFERENCES "user_type"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Customer" ADD CONSTRAINT "Customer_avatarFileId_fkey" FOREIGN KEY ("avatarFileId") REFERENCES "UploadedFile"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Workshop" ADD CONSTRAINT "Workshop_supervisorId_fkey" FOREIGN KEY ("supervisorId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "permission" ADD CONSTRAINT "permission_userTypeId_fkey" FOREIGN KEY ("userTypeId") REFERENCES "user_type"("id") ON DELETE CASCADE ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Comment" ADD CONSTRAINT "Comment_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Comment" ADD CONSTRAINT "Comment_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Comment" ADD CONSTRAINT "Comment_replyToId_fkey" FOREIGN KEY ("replyToId") REFERENCES "Comment"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Promotion" ADD CONSTRAINT "Promotion_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Promotion" ADD CONSTRAINT "Promotion_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Promotion" ADD CONSTRAINT "Promotion_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Promotion" ADD CONSTRAINT "Promotion_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "BranchAllocation" ADD CONSTRAINT "BranchAllocation_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE RESTRICT ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "BranchAllocation" ADD CONSTRAINT "BranchAllocation_allocatorAdminUserId_fkey" FOREIGN KEY ("allocatorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "BranchAllocationItem" ADD CONSTRAINT "BranchAllocationItem_allocationId_fkey" FOREIGN KEY ("allocationId") REFERENCES "BranchAllocation"("id") ON DELETE CASCADE ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "BranchAllocationItem" ADD CONSTRAINT "BranchAllocationItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "SmsMessage" ADD CONSTRAINT "SmsMessage_sentByAdminUserId_fkey" FOREIGN KEY ("sentByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "Notification" ADD CONSTRAINT "Notification_sentByAdminUserId_fkey" FOREIGN KEY ("sentByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "NotificationRecipient" ADD CONSTRAINT "NotificationRecipient_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "NotificationRecipient" ADD CONSTRAINT "NotificationRecipient_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "CustomerActivityLog" ADD CONSTRAINT "CustomerActivityLog_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN ALTER TABLE "UploadedFile" ADD CONSTRAINT "UploadedFile_uploadedByAdminUserId_fkey" FOREIGN KEY ("uploadedByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Placeholder: the original migration.sql for this migration was lost from
|
||||
-- disk (directory existed but was empty) while the database's
|
||||
-- _prisma_migrations ledger already recorded it as applied and finished.
|
||||
-- Recreated as an empty no-op file, then registered via
|
||||
-- `prisma migrate resolve --applied`, so Prisma tooling stops blocking on
|
||||
-- P3015 without re-running or guessing at SQL that already ran against this
|
||||
-- database. The actual schema effect (CustomerKind.RETAIL) is present in
|
||||
-- schema.prisma and already exists in the live database.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
-- Placeholder: this migration is recorded as applied in the database's
|
||||
-- _prisma_migrations ledger, but no migration.sql for it ever existed on
|
||||
-- disk in this checkout (added Customer.firstName/lastName/productPercent
|
||||
-- directly against the live DB, schema.prisma was never regenerated from it
|
||||
-- as a tracked migration). Recreated as an empty no-op file purely so Prisma
|
||||
-- CLI tooling (migrate status/dev) stops blocking on a missing-migration
|
||||
-- error. Does not re-run or alter anything — the columns already exist live.
|
||||
--
|
||||
-- NOT reconciled with schema.prisma: Customer.firstName, Customer.lastName,
|
||||
-- Customer.productPercent exist in the database but have no field in
|
||||
-- prisma/schema.prisma's Customer model. This is a pre-existing drift,
|
||||
-- separate from this change, and should be looked at on its own.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
-- Placeholder: this migration is recorded as applied in the database's
|
||||
-- _prisma_migrations ledger, but no migration.sql for it ever existed on
|
||||
-- disk in this checkout (added Customer.parentAgentId + Customer.birthDate
|
||||
-- directly against the live DB; schema.prisma was never regenerated from it
|
||||
-- as a tracked migration). Recreated as an empty no-op file purely so Prisma
|
||||
-- CLI tooling (migrate status/dev) stops blocking on a missing-migration
|
||||
-- error. Does not re-run or alter anything — the columns already exist live.
|
||||
--
|
||||
-- NOT reconciled with schema.prisma: Customer.parentAgentId,
|
||||
-- Customer.birthDate exist in the database but have no field in
|
||||
-- prisma/schema.prisma's Customer model. This is a pre-existing drift,
|
||||
-- separate from this change, and should be looked at on its own.
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProductImageRole" AS ENUM ('FEATURED', 'FEATURED_MOBILE', 'GALLERY', 'GALLERY_MOBILE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProductImage" ADD COLUMN "role" "ProductImageRole" NOT NULL DEFAULT 'GALLERY';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProductVariant" ADD COLUMN "imageId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProductVariant_imageId_idx" ON "ProductVariant"("imageId");
|
||||
|
||||
-- AddForeignKey
|
||||
-- ON DELETE SET NULL: removing a gallery image must never cascade into the
|
||||
-- variant, because OrderItem rows reference variants and losing one would
|
||||
-- break order history. The variant simply loses its picture.
|
||||
ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Sales agents ("کارشناس فروش") are modelled as AdminUser rows
|
||||
-- (role=SALES_EXPERT), not Customer rows — see admin-users.service.ts and
|
||||
-- admin-customers.service.ts's listReagents(). ReagentCustomer.vue used to
|
||||
-- post agent data at the Customer endpoints instead, so agents ended up as
|
||||
-- Customer rows (kind SALES_AGENT), which then leaked into every
|
||||
-- Customer-backed list, including "لیست مشتریان تک" (listSingle had no kind
|
||||
-- filter at all). Fixed at the application layer alongside this migration;
|
||||
-- this migration only adds what that fix needs:
|
||||
--
|
||||
-- 1. CustomerKind.RETAIL — legacy kept retail ("تک") customers in an
|
||||
-- entirely separate table (SingleCustomer/`login`) that could never
|
||||
-- contain a wholesale/agent/shopkeeper/banker row. The redesigned
|
||||
-- schema folded everything into one Customer model, so listSingle()
|
||||
-- needs an explicit kind to filter on to reproduce that separation.
|
||||
-- 2. AdminUser gets the handful of Customer-only columns
|
||||
-- (ReagentCustomer.vue) that a sales agent's form actually collects:
|
||||
-- avatar, address, store/pasazh name, member code. Only ever set when
|
||||
-- role=SALES_EXPERT; every other AdminUser leaves them null.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "CustomerKind" ADD VALUE IF NOT EXISTS 'RETAIL';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AdminUser"
|
||||
ADD COLUMN "avatarFileId" TEXT,
|
||||
ADD COLUMN "address" TEXT,
|
||||
ADD COLUMN "storeName" TEXT,
|
||||
ADD COLUMN "pasazhName" TEXT,
|
||||
ADD COLUMN "memberCode" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminUser_avatarFileId_idx" ON "AdminUser"("avatarFileId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_avatarFileId_fkey"
|
||||
FOREIGN KEY ("avatarFileId") REFERENCES "UploadedFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Customer OTP login challenges.
|
||||
--
|
||||
-- Additive only: creates one new table and its indexes. No existing table,
|
||||
-- column or row is touched.
|
||||
--
|
||||
-- Ports henza_server's SMS login (ApiMobileController.php:39-78). The reference
|
||||
-- kept the code in plain text on the customer row with no expiry and no attempt
|
||||
-- limit; this stores a bcrypt hash and adds both, per docs/parity/DECISIONS.md
|
||||
-- D6 / T1.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "CustomerOtp" (
|
||||
"id" TEXT NOT NULL,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"requestIp" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CustomerOtp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "CustomerOtp_mobile_createdAt_idx"
|
||||
ON "CustomerOtp"("mobile", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "CustomerOtp_customerId_idx"
|
||||
ON "CustomerOtp"("customerId");
|
||||
CREATE INDEX IF NOT EXISTS "CustomerOtp_expiresAt_idx"
|
||||
ON "CustomerOtp"("expiresAt");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "CustomerOtp"
|
||||
ADD CONSTRAINT "CustomerOtp_customerId_fkey"
|
||||
FOREIGN KEY ("customerId") REFERENCES "Customer"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -0,0 +1,97 @@
|
||||
-- D0 — split Customer.fullName into firstName / lastName (ADDITIVE).
|
||||
--
|
||||
-- Reference: henza_server has two separate columns from the start
|
||||
-- database/migrations/2020_10_01_120430_customers_table.php:18-19
|
||||
-- $table->string('fname')->nullable();
|
||||
-- $table->string('lname')->nullable();
|
||||
-- The target collapsed them into one `fullName`, so the admin edit form shows
|
||||
-- the whole name in the "first name" box and saves an empty last name
|
||||
-- (admin-customers.service.ts:231-233 returns `fname: c.fullName, lname: ''`).
|
||||
--
|
||||
-- NOTHING IS DROPPED. `fullName` stays and is kept in sync as
|
||||
-- `firstName || ' ' || lastName`, so every existing reader keeps working
|
||||
-- (D0 requirement 4).
|
||||
--
|
||||
-- D13 / storefront impact: `Customer` is a SHARED table — both the B2B app and
|
||||
-- the retail storefront read it. This migration only ADDS two nullable columns
|
||||
-- and leaves `fullName` populated and authoritative, so no storefront query,
|
||||
-- view or report changes behaviour. No storefront column is touched.
|
||||
|
||||
-- 1. The two new columns. Nullable: a customer may legitimately have no
|
||||
-- surname, and the reference's own columns are nullable.
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
|
||||
|
||||
-- 2. Backfill: first whitespace-separated token -> firstName, the REST ->
|
||||
-- lastName (D0 requirement 2). Multi-space and leading/trailing whitespace
|
||||
-- are normalised first so " علی محمدی " splits correctly.
|
||||
--
|
||||
-- Rows with a single token get firstName = that token and lastName = NULL.
|
||||
-- Rows with more than two tokens put every remaining token in lastName;
|
||||
-- they are ALSO listed in the review report (see the companion script) so a
|
||||
-- human can correct them — the split cannot be guessed safely
|
||||
-- (D0 requirement 3).
|
||||
UPDATE "Customer"
|
||||
SET
|
||||
"firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1),
|
||||
"lastName" = NULLIF(
|
||||
substr(
|
||||
btrim(regexp_replace("fullName", '\s+', ' ', 'g')),
|
||||
length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
|
||||
),
|
||||
''
|
||||
)
|
||||
WHERE "firstName" IS NULL
|
||||
AND "fullName" IS NOT NULL
|
||||
AND btrim("fullName") <> '';
|
||||
|
||||
-- 3. Keep `fullName` in sync from now on.
|
||||
--
|
||||
-- Writers may update either the parts or the whole; this trigger makes the
|
||||
-- parts authoritative whenever they are the thing that changed, and
|
||||
-- otherwise leaves an explicit `fullName` write alone. That way old code
|
||||
-- that still writes only `fullName` is not broken (D0 requirement 4).
|
||||
-- Precedence when BOTH the parts and fullName change in the SAME statement:
|
||||
-- THE PARTS WIN. fullName is rebuilt from them and the submitted fullName is
|
||||
-- discarded. Rationale: after D0 the parts are the field the forms edit, so
|
||||
-- they carry the operator's intent; fullName is the derived, legacy-facing
|
||||
-- value. Choosing the opposite would silently undo an edit made in the two
|
||||
-- boxes the operator actually typed into.
|
||||
-- The first branch below tests the parts, so it wins by construction.
|
||||
CREATE OR REPLACE FUNCTION customer_sync_full_name() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
-- The name parts changed (or are being set on insert): rebuild fullName.
|
||||
-- This is checked FIRST, so when both change in one statement the parts win.
|
||||
IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL))
|
||||
OR (TG_OP = 'UPDATE'
|
||||
AND (NEW."firstName" IS DISTINCT FROM OLD."firstName"
|
||||
OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN
|
||||
NEW."fullName" := btrim(
|
||||
coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '')
|
||||
);
|
||||
|
||||
-- Only fullName changed: derive the parts from it, so the two never drift.
|
||||
ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName"
|
||||
AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN
|
||||
NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1);
|
||||
NEW."lastName" := NULLIF(
|
||||
substr(
|
||||
btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')),
|
||||
length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
|
||||
),
|
||||
''
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS customer_sync_full_name_trg ON "Customer";
|
||||
CREATE TRIGGER customer_sync_full_name_trg
|
||||
BEFORE INSERT OR UPDATE ON "Customer"
|
||||
FOR EACH ROW EXECUTE FUNCTION customer_sync_full_name();
|
||||
|
||||
-- 4. Name search hits both parts in the admin grid.
|
||||
CREATE INDEX IF NOT EXISTS "Customer_firstName_idx" ON "Customer" ("firstName");
|
||||
CREATE INDEX IF NOT EXISTS "Customer_lastName_idx" ON "Customer" ("lastName");
|
||||
@@ -0,0 +1,49 @@
|
||||
-- D9 — add Customer.makingChargeOffsetPercent (ADDITIVE, behaviour-neutral).
|
||||
--
|
||||
-- Reference: `customers.percent`, a real per-customer column.
|
||||
-- database/migrations/2020_10_19_153228_customer_add.php:32
|
||||
-- $table->double('percent')->default(0);
|
||||
--
|
||||
-- What it does in the reference:
|
||||
-- * it is ADDED to the variant's making charge when the app builds an order
|
||||
-- line: henza_pwa/src/Mixins/product/index.ts:69-75
|
||||
-- getWage(v) = myStore.state.percent + v.percentage_pay
|
||||
-- and `state.percent` is this column, delivered by POST /me-data
|
||||
-- (henza_pwa/src/store/myDataStore.ts:29,59,122-127).
|
||||
-- * it shifts the catalogue making-charge filter bounds:
|
||||
-- ApiMobileController.php:391-396 and :76-77.
|
||||
-- * it is NOT part of the price formula itself — the extra percent there
|
||||
-- comes from ConfigStatic['product_price_extra_percent']
|
||||
-- (ProductVariant.php:50,183).
|
||||
--
|
||||
-- Why this name and not `productPercent`: the reference has three unrelated
|
||||
-- things called `percent` (see docs/parity/MIGRATION-STATE.md). This is the
|
||||
-- per-customer making-charge offset. It is mapped back to the legacy key
|
||||
-- `percent` in toRow(), so the admin panel's field name does not change.
|
||||
-- No duplicate field is created.
|
||||
--
|
||||
-- The target currently reads `referredByAgent.wagePercent` instead, which is
|
||||
-- the wrong field entirely (CUST-02) — that is corrected in the service layer,
|
||||
-- not here.
|
||||
--
|
||||
-- D13 / storefront impact: NONE. `customers.percent` is a B2B wholesale field;
|
||||
-- it is consumed only by the B2B app's cart and catalogue filter. The
|
||||
-- storefront's own percent is ConfigStatic['percent'] (the wallet cashback
|
||||
-- rate), a different key in a different table, which this migration does not
|
||||
-- touch. `Customer` is a shared table, but adding a column with a default that
|
||||
-- reproduces today's behaviour changes no storefront read.
|
||||
|
||||
-- DEFAULT 0 is deliberate and behaviour-neutral:
|
||||
-- * it matches the reference's own column default (customer_add.php:32);
|
||||
-- * with 0 the computed wage is `0 + variant.percentage_pay`, i.e. exactly
|
||||
-- the making charge alone — which is what the target does today, since it
|
||||
-- has no such column. So no existing customer's price moves.
|
||||
-- NOTE: the reference's createCustomer() writes 3, not 0, for NEW customers
|
||||
-- (AdminController.php:338) and makes the field required on create (:354).
|
||||
-- That is an application-layer default for new rows, NOT a backfill value,
|
||||
-- and it is handled in the service, not in this migration.
|
||||
ALTER TABLE "Customer"
|
||||
ADD COLUMN IF NOT EXISTS "makingChargeOffsetPercent" DECIMAL(6,3) NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN "Customer"."makingChargeOffsetPercent" IS
|
||||
'Legacy customers.percent — per-customer making-charge offset, ADDED to ProductVariant.defaultWagePercent when pricing an order line, and used to shift the catalogue making-charge filter bounds. Not the wallet cashback rate (that is the storefront ConfigStatic[percent]) and not the wholesale per-line charge (that is OrderItem.wagePercentSnapshot).';
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
-- D17 — SalesExpertProfile, and firstName/lastName on AdminUser.
|
||||
--
|
||||
-- Two additive changes in one migration:
|
||||
--
|
||||
-- 1. SalesExpertProfile — the sales-expert fields that legacy kept on the
|
||||
-- `customers` row (type=1) and that were lost when a sales expert became
|
||||
-- an AdminUser. A separate table, not columns on AdminUser, because these
|
||||
-- are meaningful only for role = SALES_EXPERT and would be permanently
|
||||
-- null for every other admin.
|
||||
--
|
||||
-- 2. AdminUser.firstName / lastName — closes D0 requirement 7, applying the
|
||||
-- same split the Customer table already got. For ALL admins, not only
|
||||
-- sales experts.
|
||||
--
|
||||
-- NOTHING IS DROPPED. `fullName` stays and is kept in sync by a trigger.
|
||||
--
|
||||
-- IDEMPOTENT (D14/D15): this must run cleanly under BOTH paths — the Prisma CLI
|
||||
-- in development, and the app's own runner (src/prisma/migrate.ts) at container
|
||||
-- boot, which may replay it. Hence IF NOT EXISTS everywhere, CREATE OR REPLACE
|
||||
-- for the function, DROP ... IF EXISTS before CREATE TRIGGER, and a guarded
|
||||
-- backfill that only touches rows not already filled.
|
||||
--
|
||||
-- D13 / storefront impact: NONE. AdminUser is an admin-side table the retail
|
||||
-- storefront never reads, and SalesExpertProfile is new. `customers` is not
|
||||
-- touched.
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 1. SalesExpertProfile
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "SalesExpertProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"adminUserId" TEXT NOT NULL,
|
||||
-- legacy customers.city_id — a filterable column on the sales-expert grid
|
||||
-- (ReagentListModel.js:14) AND the source of the commission autofill
|
||||
-- (ReagentCustomer.vue:215-228).
|
||||
"cityId" TEXT,
|
||||
-- legacy customers.gram_level — the «سطح اعتبار» column, filterable
|
||||
-- (ReagentListModel.js:17).
|
||||
--
|
||||
-- DECIMAL(14,4) deliberately matches Customer.goldCreditLimitGrams exactly
|
||||
-- (schema.prisma:221, verified numeric_precision=14 numeric_scale=4), so the
|
||||
-- same credit limit has identical precision and rendering on both tables.
|
||||
"gramLevel" DECIMAL(14,4) NOT NULL DEFAULT 0,
|
||||
-- legacy customers.phone / ncode — collected by the reference form
|
||||
-- (ReagentCustomer.vue:95,31) and stored, though nothing reads them back.
|
||||
-- Added on the owner's instruction so the form matches the reference.
|
||||
--
|
||||
-- Types and constraints copied from the reference, which is deliberately lax:
|
||||
-- ncode : string(20), NULLABLE, **NOT UNIQUE**
|
||||
-- (2020_10_01_120430_customers_table.php:22)
|
||||
-- phone : string(15), NULLABLE, not unique
|
||||
-- (2020_10_25_155247_customers_cols.php:17)
|
||||
-- Neither has a server-side validation rule: createCustomer validates only
|
||||
-- `mobile` (required|unique) and `percent` (required) — AdminController.php:354.
|
||||
-- Client-side both are `required: false` (ReagentCustomer.vue:31,95).
|
||||
-- No uniqueness is invented here; that would reject data the reference accepts.
|
||||
"phone" VARCHAR(15),
|
||||
"nationalCode" VARCHAR(20),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "SalesExpertProfile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- One profile per admin user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_adminUserId_key"
|
||||
ON "SalesExpertProfile" ("adminUserId");
|
||||
|
||||
-- The grid filters on city.
|
||||
CREATE INDEX IF NOT EXISTS "SalesExpertProfile_cityId_idx"
|
||||
ON "SalesExpertProfile" ("cityId");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "SalesExpertProfile"
|
||||
ADD CONSTRAINT "SalesExpertProfile_adminUserId_fkey"
|
||||
FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "SalesExpertProfile"
|
||||
ADD CONSTRAINT "SalesExpertProfile_cityId_fkey"
|
||||
FOREIGN KEY ("cityId") REFERENCES "City"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Give every EXISTING sales expert a profile row, so the grid's city and
|
||||
-- credit-limit columns have something to left-join to from day one.
|
||||
--
|
||||
-- Idempotent by construction: `WHERE NOT EXISTS` means a replay inserts
|
||||
-- nothing, and it never touches a profile an operator has already filled in.
|
||||
--
|
||||
-- The id is derived from the AdminUser id rather than random, so a replay on a
|
||||
-- database where the row was deleted recreates the same key instead of
|
||||
-- accumulating duplicates. Values are the column defaults: gramLevel 0 and
|
||||
-- nulls elsewhere — deliberately neutral, since the reference has no data to
|
||||
-- migrate here and inventing a credit limit would change behaviour.
|
||||
INSERT INTO "SalesExpertProfile" ("id", "adminUserId", "gramLevel", "createdAt", "updatedAt")
|
||||
SELECT 'sep_' || a."id", a."id", 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM "AdminUser" a
|
||||
WHERE a."role" = 'SALES_EXPERT'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "SalesExpertProfile" s WHERE s."adminUserId" = a."id"
|
||||
);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 2. AdminUser.firstName / lastName (D0 requirement 7)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
|
||||
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
|
||||
|
||||
-- Backfill: first whitespace-separated token -> firstName, the REST ->
|
||||
-- lastName. Identical rule to the Customer migration, so both tables split the
|
||||
-- same way. Rows with more than two tokens are a guess and are listed by
|
||||
-- scripts/report-name-split.cjs for a human to correct.
|
||||
--
|
||||
-- `WHERE "firstName" IS NULL` makes the replay a no-op and, just as important,
|
||||
-- prevents a re-run from overwriting a name a human has since corrected.
|
||||
UPDATE "AdminUser"
|
||||
SET
|
||||
"firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1),
|
||||
"lastName" = NULLIF(
|
||||
substr(
|
||||
btrim(regexp_replace("fullName", '\s+', ' ', 'g')),
|
||||
length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
|
||||
),
|
||||
''
|
||||
)
|
||||
WHERE "firstName" IS NULL
|
||||
AND "fullName" IS NOT NULL
|
||||
AND btrim("fullName") <> '';
|
||||
|
||||
-- Same two-way sync as Customer.
|
||||
--
|
||||
-- Precedence when BOTH the parts and fullName change in ONE statement:
|
||||
-- THE PARTS WIN — fullName is rebuilt from them and the submitted fullName is
|
||||
-- discarded. The parts are what the forms edit, so they carry the operator's
|
||||
-- intent; fullName is the derived, legacy-facing value. The first branch tests
|
||||
-- the parts, so it wins by construction.
|
||||
CREATE OR REPLACE FUNCTION admin_user_sync_full_name() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL))
|
||||
OR (TG_OP = 'UPDATE'
|
||||
AND (NEW."firstName" IS DISTINCT FROM OLD."firstName"
|
||||
OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN
|
||||
NEW."fullName" := btrim(
|
||||
coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '')
|
||||
);
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName"
|
||||
AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN
|
||||
NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1);
|
||||
NEW."lastName" := NULLIF(
|
||||
substr(
|
||||
btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')),
|
||||
length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
|
||||
),
|
||||
''
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS admin_user_sync_full_name_trg ON "AdminUser";
|
||||
CREATE TRIGGER admin_user_sync_full_name_trg
|
||||
BEFORE INSERT OR UPDATE ON "AdminUser"
|
||||
FOR EACH ROW EXECUTE FUNCTION admin_user_sync_full_name();
|
||||
|
||||
-- Name search and sort hit both parts.
|
||||
CREATE INDEX IF NOT EXISTS "AdminUser_firstName_idx" ON "AdminUser" ("firstName");
|
||||
CREATE INDEX IF NOT EXISTS "AdminUser_lastName_idx" ON "AdminUser" ("lastName");
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Sales-expert app login: the expert's own trading account.
|
||||
--
|
||||
-- WHY THIS COLUMN EXISTS
|
||||
-- ----------------------
|
||||
-- In the reference a sales agent WAS a Customer row (customers.type = 1), so
|
||||
-- when the agent placed an order from the app, setOrder did
|
||||
-- $customer = Customer::findOrFail(auth()->id());
|
||||
-- $gramLevel = $customer->gram_level;
|
||||
-- (ApiMobileController.php:534-535) — the order was simply that agent's own
|
||||
-- order, against that agent's own credit limit and ledger.
|
||||
--
|
||||
-- This system models a sales expert as an AdminUser (role SALES_EXPERT, D17),
|
||||
-- which has no ledger, no gold debt and no credit limit. An AdminUser therefore
|
||||
-- cannot be the subject of an Order: Order.customerId is a required FK to
|
||||
-- Customer, and every ledger entry hangs off a Customer.
|
||||
--
|
||||
-- Rather than reverse D17 (the owner ruled the expert stays an AdminUser), each
|
||||
-- expert gets ONE linked Customer row that represents only their own trading
|
||||
-- position. Orders the expert places for themselves land on that row, exactly
|
||||
-- as the reference's single row did. The expert's REFERRED customers remain
|
||||
-- separate rows pointing at the AdminUser via Customer.referredByAgentId —
|
||||
-- that relationship is unchanged.
|
||||
--
|
||||
-- PURELY ADDITIVE
|
||||
-- ---------------
|
||||
-- One nullable column plus a unique index. No existing row is modified, no
|
||||
-- data is backfilled and nothing is deleted. A profile with a NULL
|
||||
-- tradingCustomerId simply has no trading account yet; the API creates it
|
||||
-- lazily on first login, so existing experts keep working untouched.
|
||||
|
||||
ALTER TABLE "SalesExpertProfile"
|
||||
ADD COLUMN IF NOT EXISTS "tradingCustomerId" TEXT;
|
||||
|
||||
COMMENT ON COLUMN "SalesExpertProfile"."tradingCustomerId" IS
|
||||
'The Customer row that carries this expert''s OWN orders, debt and credit limit. Their referred customers are separate rows linked by Customer.referredByAgentId. Null until the expert first signs into the app, when it is created from the profile (gramLevel -> goldCreditLimitGrams, wagePercent -> makingChargeOffsetPercent).';
|
||||
|
||||
-- One trading account per expert, and no two experts may share one.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_tradingCustomerId_key"
|
||||
ON "SalesExpertProfile" ("tradingCustomerId");
|
||||
|
||||
-- SET NULL, not CASCADE: if the trading Customer is ever removed, the expert
|
||||
-- and their profile must survive — deleting a customer must never delete an
|
||||
-- admin account.
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "SalesExpertProfile"
|
||||
ADD CONSTRAINT "SalesExpertProfile_tradingCustomerId_fkey"
|
||||
FOREIGN KEY ("tradingCustomerId") REFERENCES "Customer"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Revocable admin sessions.
|
||||
--
|
||||
-- Until now an admin-panel token was purely self-contained: once signed it was
|
||||
-- valid for its full 12h and nothing could cut it short — not deactivating the
|
||||
-- account, not a central "sign out everywhere". Connecting the central IAM made
|
||||
-- that gap concrete, because an IAM session revocation had no effect on a token
|
||||
-- this API had already issued.
|
||||
--
|
||||
-- Each issued token now carries a `jti` recorded here, and JwtStrategy rejects a
|
||||
-- token whose row is revoked. Rows are only written (and only enforced) while
|
||||
-- session tracking is enabled, so a deployment that leaves it off keeps exactly
|
||||
-- the previous behaviour and the previous query count.
|
||||
CREATE TABLE "AdminSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"jti" TEXT NOT NULL,
|
||||
"adminUserId" TEXT NOT NULL,
|
||||
"iamSessionId" TEXT,
|
||||
"iamSub" TEXT,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"lastSeenAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"revokedReason" TEXT,
|
||||
|
||||
CONSTRAINT "AdminSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "AdminSession_jti_key" ON "AdminSession"("jti");
|
||||
CREATE INDEX "AdminSession_adminUserId_idx" ON "AdminSession"("adminUserId");
|
||||
CREATE INDEX "AdminSession_iamSessionId_idx" ON "AdminSession"("iamSessionId");
|
||||
CREATE INDEX "AdminSession_iamSub_idx" ON "AdminSession"("iamSub");
|
||||
CREATE INDEX "AdminSession_expiresAt_idx" ON "AdminSession"("expiresAt");
|
||||
|
||||
ALTER TABLE "AdminSession"
|
||||
ADD CONSTRAINT "AdminSession_adminUserId_fkey"
|
||||
FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Revocable app sessions (customer app and sales-expert app).
|
||||
--
|
||||
-- The admin panel got this in 20260925120000_admin_session; the app token family
|
||||
-- had the same gap: a 30-day self-contained token that nothing could end early.
|
||||
-- That matters more here, because the app's login is OTP-only — there is no
|
||||
-- password an operator could change to cut access.
|
||||
--
|
||||
-- No foreign key on purpose: an app token's subject is a Customer for a customer
|
||||
-- and an AdminUser for a sales expert (D21), which one FK cannot express.
|
||||
CREATE TABLE "AppSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"jti" TEXT NOT NULL,
|
||||
"subjectId" TEXT NOT NULL,
|
||||
"subjectType" TEXT NOT NULL,
|
||||
"mobile" TEXT,
|
||||
"iamSessionId" TEXT,
|
||||
"iamSub" TEXT,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"lastSeenAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"revokedReason" TEXT,
|
||||
|
||||
CONSTRAINT "AppSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "AppSession_jti_key" ON "AppSession"("jti");
|
||||
CREATE INDEX "AppSession_subjectId_idx" ON "AppSession"("subjectId");
|
||||
CREATE INDEX "AppSession_iamSessionId_idx" ON "AppSession"("iamSessionId");
|
||||
CREATE INDEX "AppSession_iamSub_idx" ON "AppSession"("iamSub");
|
||||
CREATE INDEX "AppSession_expiresAt_idx" ON "AppSession"("expiresAt");
|
||||
@@ -0,0 +1,110 @@
|
||||
-- Accounting integration (D27): Tahesab / Zarrin, chosen in the admin panel.
|
||||
-- Plan: docs/ACCOUNTING-INTEGRATION-PLAN.md.
|
||||
--
|
||||
-- Adds the link table (app row <-> accounting record), the outbox the push
|
||||
-- jobs run from, the issue list pulls report into, and the cached balance and
|
||||
-- stock columns read back from the accounting software. Nothing existing
|
||||
-- changes meaning: goldDebtGrams/fiatDebtRial stay the internal ledger caches.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountingEntityType" AS ENUM ('CUSTOMER', 'PRODUCT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountingOutboxKind" AS ENUM ('CUSTOMER_PUSH', 'PRODUCT_PUSH', 'ORDER_DOCUMENT', 'ORDER_DOCUMENT_DELETE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountingOutboxStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Customer" ADD COLUMN "accountingBalanceAt" TIMESTAMP(3),
|
||||
ADD COLUMN "accountingGoldBalance" DECIMAL(14,4),
|
||||
ADD COLUMN "accountingRialBalance" DECIMAL(20,2);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "accountingStockAt" TIMESTAMP(3),
|
||||
ADD COLUMN "accountingStockCount" DECIMAL(14,4),
|
||||
ADD COLUMN "accountingStockWeight" DECIMAL(14,4);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"entityType" "AccountingEntityType" NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"externalCode" TEXT NOT NULL,
|
||||
"externalName" TEXT,
|
||||
"origin" TEXT NOT NULL,
|
||||
"snapshot" JSONB,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AccountingLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingOutbox" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"kind" "AccountingOutboxKind" NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"status" "AccountingOutboxStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastError" TEXT,
|
||||
"externalRef" TEXT,
|
||||
"progress" JSONB,
|
||||
"lastRequest" JSONB,
|
||||
"lastResponse" JSONB,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"resolvedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AccountingOutbox_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingIssue" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"externalCode" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"message" TEXT NOT NULL,
|
||||
"data" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "AccountingIssue_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingLink_entityType_entityId_idx" ON "AccountingLink"("entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_entityId_key" ON "AccountingLink"("provider", "entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_externalCode_key" ON "AccountingLink"("provider", "entityType", "externalCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingOutbox_status_nextAttemptAt_idx" ON "AccountingOutbox"("status", "nextAttemptAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingOutbox_kind_entityId_idx" ON "AccountingOutbox"("kind", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingIssue_resolvedAt_idx" ON "AccountingIssue"("resolvedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingIssue_provider_kind_externalCode_key" ON "AccountingIssue"("provider", "kind", "externalCode");
|
||||
|
||||
|
||||
-- The new admin screen's permission. The seeded super-admin user type (85,
|
||||
-- prisma/seed.ts) gets every rid in ALL_RIDS on a fresh seed; an existing
|
||||
-- database has to be granted the new one here.
|
||||
INSERT INTO "permission" ("rid", "userTypeId")
|
||||
SELECT 'config.accounting', 85
|
||||
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,210 @@
|
||||
-- ============================================================================
|
||||
-- Preparation for the legacy data migration — PURELY ADDITIVE.
|
||||
--
|
||||
-- Owner decisions of 2026-10-06 and 2026-10-07, recorded in
|
||||
-- docs/DATA-MIGRATION-MAP.md §5.2, §5.5, §10, §10.4, §10.5.5.
|
||||
--
|
||||
-- Nothing here drops, renames or retypes an existing column. Every statement
|
||||
-- either removes a constraint or adds a new column/table, so code that works
|
||||
-- today keeps working. Verified NOT touched:
|
||||
-- * Customer.accounting{Gold,Rial}Balance / accountingBalanceAt (D27)
|
||||
-- * Product.accountingStock{Weight,Count} / accountingStockAt (D27)
|
||||
-- * ProductVariant.imageId (PROTECTED.md §B)
|
||||
-- * AccountingLink / AccountingOutbox / AccountingIssue
|
||||
-- * SmsMessage and the SMS module
|
||||
-- These are target-only capabilities under PROTECTED.md §G ("a keep-out
|
||||
-- list"), and the owner confirmed on 2026-10-06 that their data is real.
|
||||
--
|
||||
-- D15: idempotent (IF NOT EXISTS / duplicate_object guards) because this runs
|
||||
-- under BOTH ledgers — the Prisma CLI in development and the app's own runner
|
||||
-- (src/prisma/migrate.ts) in the deploy container, which wraps each file in a
|
||||
-- transaction. The runner's splitSqlStatements() handles $$-quoted blocks
|
||||
-- (migrate.ts:83-91), so the DO blocks below are split correctly.
|
||||
-- ============================================================================
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 1. Customer.nationalCode becomes OPTIONAL
|
||||
--
|
||||
-- Source reality (docs/migration-precheck.sql, run 2026-10-06 on the live
|
||||
-- `oapi` MySQL schema): empty_ncode = 1595 of n_customers = 1595. NOT ONE of
|
||||
-- the 1,616 legacy customers has a national code. dup_ncode_groups = 0.
|
||||
--
|
||||
-- Owner ruling 2026-10-07: "ساختن کد ملی فیک تخلف دیتایی است و مانع از
|
||||
-- احراز هویتهای قانونی بعدی میشود" — fabricating one is a data violation, so
|
||||
-- the column must be nullable. Without this the customer migration cannot
|
||||
-- insert a single row.
|
||||
--
|
||||
-- This also RETIRES three existing workarounds that only exist because the
|
||||
-- column is required. They write the MOBILE NUMBER into the national-code
|
||||
-- field, i.e. they store wrong data today:
|
||||
-- * admin-customers.service.ts:625 nationalCode: mobile
|
||||
-- comment: "schema requires it and it is unique; singleCustomer.vue
|
||||
-- collects no ncode"
|
||||
-- * admin-customers.service.ts:685 nationalCode: nationalCode || mobile
|
||||
-- * customer-auth.service.ts:258 ... || admin.mobile
|
||||
-- Removing those fallbacks is a SERVICE-LAYER change, deliberately NOT done
|
||||
-- here: this migration only makes it possible. Until they are removed the
|
||||
-- behaviour is exactly as it is today.
|
||||
--
|
||||
-- The UNIQUE index "Customer_nationalCode_key" (first/migration.sql:304) is
|
||||
-- left in place. In PostgreSQL NULLs are not equal to each other, so any
|
||||
-- number of rows may hold NULL under a unique index — which is precisely why
|
||||
-- the migration tool must write NULL and never the empty string: '' would
|
||||
-- collide on the second row (see §10.4).
|
||||
--
|
||||
-- DROP NOT NULL takes only a brief ACCESS EXCLUSIVE lock on the catalog; it
|
||||
-- does not rewrite the table and does not scan the data.
|
||||
ALTER TABLE "Customer" ALTER COLUMN "nationalCode" DROP NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN "Customer"."nationalCode" IS
|
||||
'Legacy customers.ncode. OPTIONAL: no legacy customer has one (1595/1595 empty as of 2026-10-06), and fabricating a value is a data violation (owner, 2026-10-07). Still UNIQUE — write NULL, never '''', because NULLs do not collide under a unique index but empty strings do.';
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 2. Customer.birthDate — new, nullable, unread until backfilled
|
||||
--
|
||||
-- Legacy `customers.birth_date timestamp NULL` (oapi dump line 484), populated
|
||||
-- on 917 of 1,595 live rows (~57%). Owner ruling 2026-10-07: migrate it —
|
||||
-- "نرخ پرشدگی بالای ۵۵٪ است و داده برای کمپینهای وفاداری و پیامکی کاربرد
|
||||
-- مستقیم دارد".
|
||||
--
|
||||
-- Two sibling columns were deliberately NOT ported, same ruling:
|
||||
-- * marriage_date (5 rows) — "۵ ردیف توجیه معماری ندارد"
|
||||
-- * token_mobile (99 rows) — "توکنها اغلب منقضی شدهاند · زباله دیتایی"
|
||||
--
|
||||
-- ⚠ THE COLUMN IS CREATED EMPTY ON PURPOSE. Do not backfill it until the
|
||||
-- calendar question is settled (§5.9): legacy stores some dates as JALALI
|
||||
-- STRINGS elsewhere (cashes.recive_date is varchar(255), 88 of 89 values
|
||||
-- starting "14…"), so birth_date may hold Jalali years mis-entered into a
|
||||
-- timestamp. Copying those unchanged would silently produce 917 WRONG
|
||||
-- birthdays, which for an SMS campaign is worse than having none.
|
||||
-- docs/migration-precheck-3.sql answers this.
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "birthDate" TIMESTAMP(3);
|
||||
|
||||
COMMENT ON COLUMN "Customer"."birthDate" IS
|
||||
'Legacy customers.birth_date (917 of 1595 rows populated). Created empty: verify Jalali-vs-Gregorian with docs/migration-precheck-3.sql BEFORE backfilling — legacy stores dates as Jalali strings in other tables.';
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 3. Rejection trail for document verification
|
||||
--
|
||||
-- Legacy has THREE states per verification step, not two
|
||||
-- (MajorCustomerController.php:106-135): 1 = awaiting review, 2 = approved,
|
||||
-- 3 = REJECTED. The target's VerificationStep enum has no rejected state, so
|
||||
-- 14 already-rejected customers had nowhere to go.
|
||||
--
|
||||
-- Owner ruling 2026-10-07: mapping them to "awaiting" is "کاملاً مردود" —
|
||||
-- it would put closed cases back in the review queue and create duplicate
|
||||
-- work. REJECTED is added to the enum (statement 4 below) and these two
|
||||
-- columns carry the reason.
|
||||
--
|
||||
-- Legacy records NO reason: all 49 `customers` columns were checked and none
|
||||
-- is a reason/note/description field, and on rejection the server sends only
|
||||
-- a FIXED SMS ("اطلاعات شما تایید نشده است. لطفا با پیشتیبانی تماس حاصل
|
||||
-- فرمایید") with no cause stored anywhere. So for those 14 rows the reason is
|
||||
-- unrecoverable; the owner asked the tool to write an explicit sentinel rather
|
||||
-- than NULL, so support knows WHY it is blank:
|
||||
-- رد شده در سیستم قدیم — فاقد شرح علت ثبتشده
|
||||
-- That text is written by the migration tool, not by this migration, and only
|
||||
-- WHERE verificationRejectionReason IS NULL so a re-run can never overwrite a
|
||||
-- real reason entered in the new system (§5.5).
|
||||
--
|
||||
-- verificationRejectedAt stays NULL for the 14 legacy rows: legacy did not
|
||||
-- record a rejection timestamp either.
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "verificationRejectionReason" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "verificationRejectedAt" TIMESTAMP(3);
|
||||
|
||||
COMMENT ON COLUMN "Customer"."verificationRejectionReason" IS
|
||||
'Why document verification was rejected. Legacy stored no reason (only a fixed SMS), so rows migrated from henza carry the sentinel «رد شده در سیستم قدیم — فاقد شرح علت ثبتشده». Write only WHERE this IS NULL so a re-run never overwrites a real reason.';
|
||||
|
||||
COMMENT ON COLUMN "Customer"."verificationRejectedAt" IS
|
||||
'When verification was rejected. NULL for rows migrated from henza — legacy did not record a rejection timestamp.';
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 4. VerificationStep gains REJECTED
|
||||
--
|
||||
-- ADD VALUE is additive: existing rows and existing queries are unaffected.
|
||||
-- IF NOT EXISTS makes a re-run a no-op.
|
||||
--
|
||||
-- ⚠ CONSUMER AUDIT STILL OWED. Every read of VerificationStep must be checked
|
||||
-- for a Persian label / switch that does not yet know this value, or the admin
|
||||
-- panel will render a blank cell. Flagged in §10.5.5 as a separate task; it is
|
||||
-- a display gap, not a data risk, and nothing writes REJECTED until the
|
||||
-- migration tool runs.
|
||||
--
|
||||
-- Postgres note: before v12 ADD VALUE could not run inside a transaction, and
|
||||
-- the app's own runner wraps each file in one (migrate.ts:198). This project
|
||||
-- targets a modern Postgres where that restriction is lifted. If the deploy
|
||||
-- container ever errors with "ALTER TYPE ... ADD VALUE cannot be executed
|
||||
-- inside a transaction block", this statement must move to its own file.
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TYPE "VerificationStep" ADD VALUE IF NOT EXISTS 'REJECTED';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 5. CustomerFavorite — the legacy `fav` table finally gets a home
|
||||
--
|
||||
-- Legacy `fav` holds 251 rows (customer_id, product_id) and the target had NO
|
||||
-- model for it, so every customer's saved products would have been lost.
|
||||
-- Owner ruling 2026-10-06: they matter. docs/PWA-PORT-PLAN.md reached the same
|
||||
-- conclusion independently.
|
||||
--
|
||||
-- Legacy `fav` has only two columns and NO primary key, so duplicate pairs are
|
||||
-- possible in principle. The composite primary key here closes that, and the
|
||||
-- precheck confirmed fav_duplicate_pairs = 0, so no de-duplication is needed —
|
||||
-- but the tool should still SELECT DISTINCT, since the source cannot enforce it.
|
||||
--
|
||||
-- 14 of the 251 rows point at a customer that no longer exists
|
||||
-- (fav.customer_id orphans). Those must be SKIPPED by the tool; the foreign
|
||||
-- keys below would reject them anyway.
|
||||
--
|
||||
-- ON DELETE CASCADE on both sides: a favourite is a pure join row with no
|
||||
-- meaning once either end is gone. This differs from ProductVariant.imageId,
|
||||
-- which uses SetNull precisely so a deleted image never cascades into order
|
||||
-- history — the distinction is deliberate.
|
||||
--
|
||||
-- Legacy also keeps a denormalised `product.count_fav` counter. It is NOT
|
||||
-- recreated as a column; derive it from this table (§5.2 group B) rather than
|
||||
-- showing a stale or zero value.
|
||||
CREATE TABLE IF NOT EXISTS "CustomerFavorite" (
|
||||
"customerId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "CustomerFavorite_pkey" PRIMARY KEY ("customerId", "productId")
|
||||
);
|
||||
|
||||
-- The PK already indexes (customerId, productId) left-to-right, so "my
|
||||
-- favourites" needs no extra index. This one serves the reverse lookup —
|
||||
-- "how many customers favourited this product" — which is how count_fav is
|
||||
-- derived.
|
||||
CREATE INDEX IF NOT EXISTS "CustomerFavorite_productId_idx"
|
||||
ON "CustomerFavorite"("productId");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "CustomerFavorite"
|
||||
ADD CONSTRAINT "CustomerFavorite_customerId_fkey"
|
||||
FOREIGN KEY ("customerId") REFERENCES "Customer"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "CustomerFavorite"
|
||||
ADD CONSTRAINT "CustomerFavorite_productId_fkey"
|
||||
FOREIGN KEY ("productId") REFERENCES "Product"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE "CustomerFavorite" IS
|
||||
'Legacy `fav` (251 rows). Composite PK replaces the source table''s missing primary key. Derive product.count_fav from here rather than storing a counter.';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- ============================================================================
|
||||
-- Customer.cityNameRaw — PURELY ADDITIVE.
|
||||
--
|
||||
-- Owner ruling 2026-10-07 (docs/DATA-MIGRATION-MAP.md §5.9): keep the city a
|
||||
-- customer typed at sign-up verbatim, and set cityId only on an exact match.
|
||||
--
|
||||
-- Legacy `customers.new_city` is free text (all 172 live values are names, 0
|
||||
-- are ids). The legacy city table has only 15 rows, so 43 of those names match
|
||||
-- no known city at all, and only ~72 match one of the 15 after ي/ك
|
||||
-- normalisation. Dropping the text would lose where those customers are.
|
||||
--
|
||||
-- Legacy sign-up also forced city_id = 5 (تهران) on every new customer
|
||||
-- (Major/CustomerController.php:21); 91 of the 92 customers with both values
|
||||
-- carry that forced Tehran. For them the typed name is the truth.
|
||||
--
|
||||
-- D15: idempotent, runs under both migration ledgers.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "cityNameRaw" TEXT;
|
||||
|
||||
COMMENT ON COLUMN "Customer"."cityNameRaw" IS
|
||||
'Legacy customers.new_city — city name as typed at sign-up, kept verbatim. cityId is set too only on an exact (ي/ك-normalised) match; legacy forced city_id=5 (Tehran) on sign-up, so prefer this text over that cityId.';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ============================================================================
|
||||
-- Customer identity cleanup — data only, no schema change. Idempotent.
|
||||
--
|
||||
-- Owner ruling 2026-10-07: «هر دیتایی باید سر جای خودش بشینه».
|
||||
--
|
||||
-- 1. nationalCode held the MOBILE (or "<mobile>-<provider>-<code>") on every
|
||||
-- customer created without a national code, because the column used to be
|
||||
-- NOT NULL. It is optional since 20261007120000, and the code paths that
|
||||
-- wrote those stand-ins are fixed in the same change as this file. The
|
||||
-- stand-ins are cleared to NULL; a real national code is never touched.
|
||||
-- Accounting is unaffected: it only ever received validNationalCode(),
|
||||
-- which already mapped a mobile to null.
|
||||
--
|
||||
-- 2. passwordHash '' becomes the explicit "no password login" marker
|
||||
-- (auth/password-hash.ts). Both are refused before bcrypt by
|
||||
-- compareLocalHash, so nobody's ability to sign in changes.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE "Customer"
|
||||
SET "nationalCode" = NULL
|
||||
WHERE "nationalCode" = ''
|
||||
OR "nationalCode" = "mobile"
|
||||
OR "nationalCode" LIKE "mobile" || '-%';
|
||||
|
||||
UPDATE "Customer" SET "passwordHash" = '!OTP_ONLY_NO_PASSWORD_LOGIN' WHERE "passwordHash" = '';
|
||||
UPDATE "AdminUser" SET "passwordHash" = '!NO_PASSWORD_SET_YET' WHERE "passwordHash" = '';
|
||||
|
||||
-- The earlier comment named the wrong source table: `fav` belongs to the retail
|
||||
-- shop; wholesale favourites come from `major_favorites` (DATA-MIGRATION-MAP §10.7).
|
||||
COMMENT ON TABLE "CustomerFavorite" IS
|
||||
'Legacy `major_favorites` (wholesale; deduplicated on import). Composite PK replaces the source table''s missing primary key. Derive a favourite count from here rather than storing a counter.';
|
||||
@@ -0,0 +1,47 @@
|
||||
-- ============================================================================
|
||||
-- Order.salesAgentId / Order.referrerCustomerId — additive, idempotent.
|
||||
--
|
||||
-- Owner ruling 2026-10-08: keep legacy's rule exactly. An order is credited to
|
||||
-- the expert who was the customer's referrer WHEN IT WAS PLACED (legacy
|
||||
-- `orders.reagent_id`, set once at creation), so a customer moving to another
|
||||
-- expert later never changes past sales or commission. The app used to credit
|
||||
-- an order to the customer's CURRENT referrer, which disagreed with legacy for
|
||||
-- 8 of 49 experts on the imported data.
|
||||
--
|
||||
-- No row is deleted and no existing column changes.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "salesAgentId" TEXT;
|
||||
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "referrerCustomerId" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_salesAgentId_fkey"
|
||||
FOREIGN KEY ("salesAgentId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_referrerCustomerId_fkey"
|
||||
FOREIGN KEY ("referrerCustomerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Order_salesAgentId_idx" ON "Order"("salesAgentId");
|
||||
CREATE INDEX IF NOT EXISTS "Order_referrerCustomerId_idx" ON "Order"("referrerCustomerId");
|
||||
|
||||
-- Orders placed in the new app before this column existed: the customer's
|
||||
-- referrer today is the best record there is (these are recent). Orders
|
||||
-- brought over from legacy (orderNumber 'H-<id>') are NOT guessed here — the
|
||||
-- import writes their real `orders.reagent_id`.
|
||||
UPDATE "Order" o
|
||||
SET "salesAgentId" = c."referredByAgentId"
|
||||
FROM "Customer" c
|
||||
WHERE c."id" = o."customerId"
|
||||
AND o."salesAgentId" IS NULL
|
||||
AND c."referredByAgentId" IS NOT NULL
|
||||
AND o."orderNumber" NOT LIKE 'H-%';
|
||||
|
||||
COMMENT ON COLUMN "Order"."salesAgentId" IS
|
||||
'Sales expert credited with this order, fixed when it is placed (legacy orders.reagent_id). Never follows a later change of the customer''s referrer.';
|
||||
COMMENT ON COLUMN "Order"."referrerCustomerId" IS
|
||||
'History only: legacy orders.reagent_id when it named a customer who is not a sales expert. Never set for new orders.';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- «اجرت» admin screen (owner ruling 2026-10-08): its permission for the
|
||||
-- seeded super-admin user type. A fresh seed grants every rid in ALL_RIDS;
|
||||
-- an existing database gets the new one here. Data only, idempotent.
|
||||
-- The formula itself needs no row: until an admin approves one, the server
|
||||
-- uses legacy's formula (pricing/wage-formula.ts LEGACY_WAGE_FORMULA).
|
||||
INSERT INTO "permission" ("rid", "userTypeId")
|
||||
SELECT 'config.wage', 85
|
||||
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- PERM-01 (owner approval 2026-10-08): legacy's two dashboard permissions
|
||||
-- «آمار عمده فروشی» (dashboard.omde) and «آمار تک فروشی» (dashboard.tak), and
|
||||
-- their tree parent "dashboard", became the single "dashboard.view" when the two
|
||||
-- dashboards became one. A database seeded before that change still holds the
|
||||
-- old rows: nothing reads them and the permission screen cannot show or revoke
|
||||
-- them. Whoever held one keeps the dashboard through "dashboard.view", then the
|
||||
-- old rows go. Data only, idempotent.
|
||||
INSERT INTO "permission" ("rid", "userTypeId")
|
||||
SELECT DISTINCT 'dashboard.view', "userTypeId"
|
||||
FROM "permission"
|
||||
WHERE "rid" IN ('dashboard', 'dashboard.omde', 'dashboard.tak')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
DELETE FROM "permission" WHERE "rid" IN ('dashboard', 'dashboard.omde', 'dashboard.tak');
|
||||
@@ -0,0 +1,33 @@
|
||||
-- PERS-02 (owner approval 2026-10-08): cities become a tree again, as in legacy
|
||||
-- (country > province > city > district, henza `cities.parent`). The flat
|
||||
-- `province` label is turned into parent rows, nothing is lost, then dropped.
|
||||
|
||||
ALTER TABLE "City" ADD COLUMN "parentId" TEXT;
|
||||
|
||||
-- One province row per distinct label, named as legacy names them
|
||||
-- («استان تهران»), unless a city row already carries that name.
|
||||
INSERT INTO "City" ("id", "name")
|
||||
SELECT 'cprov' || substr(md5(p.pname), 1, 20), p.pname
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
CASE WHEN btrim("province") LIKE 'استان%' THEN btrim("province") ELSE 'استان ' || btrim("province") END AS pname
|
||||
FROM "City"
|
||||
WHERE "province" IS NOT NULL AND btrim("province") <> ''
|
||||
) p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "City" c WHERE c."name" = p.pname);
|
||||
|
||||
UPDATE "City" c
|
||||
SET "parentId" = (
|
||||
SELECT p."id" FROM "City" p
|
||||
WHERE p."id" <> c."id"
|
||||
AND p."name" = CASE WHEN btrim(c."province") LIKE 'استان%' THEN btrim(c."province") ELSE 'استان ' || btrim(c."province") END
|
||||
ORDER BY p."id"
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE c."province" IS NOT NULL AND btrim(c."province") <> '';
|
||||
|
||||
ALTER TABLE "City" DROP COLUMN "province";
|
||||
|
||||
CREATE INDEX "City_parentId_idx" ON "City"("parentId");
|
||||
|
||||
ALTER TABLE "City" ADD CONSTRAINT "City_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Catalogue paging indexes.
|
||||
--
|
||||
-- Owner, 2026-10-12: «دیتاها در اپ واقعی تا 16 هزار رکورد میرود و گرفتن همه یک
|
||||
-- جا اشتباه است، حتما باید پیج نیشن داشته باشد.» Step 1 of
|
||||
-- docs/CATALOG-PAGINATION-PLAN.md: without these, server-side paging would walk
|
||||
-- the same full table scans the unpaged query did.
|
||||
--
|
||||
-- `Product` carried NO index whatsoever, so every GET /catalog/products was a
|
||||
-- sequential scan, and so was each per-product include.
|
||||
--
|
||||
-- IF NOT EXISTS throughout: these run under BOTH migration paths (D15) — the
|
||||
-- Prisma CLI in development and the app's own runner in the deploy image.
|
||||
--
|
||||
-- CREATE INDEX (not CONCURRENTLY): the app's runner wraps each migration in a
|
||||
-- transaction, which CONCURRENTLY cannot join. On a 16k-row table this locks
|
||||
-- writes for a few hundred milliseconds, which is acceptable; a table large
|
||||
-- enough to matter would need the index built by hand outside a migration.
|
||||
|
||||
-- Matches the storefront list exactly: filter isActive, order by createdAt.
|
||||
CREATE INDEX IF NOT EXISTS "Product_isActive_createdAt_idx" ON "Product" ("isActive", "createdAt");
|
||||
|
||||
-- The category and brand filters of the search screen.
|
||||
CREATE INDEX IF NOT EXISTS "Product_categoryId_idx" ON "Product" ("categoryId");
|
||||
CREATE INDEX IF NOT EXISTS "Product_brandId_idx" ON "Product" ("brandId");
|
||||
|
||||
-- `include: { variants: { where: { isActive: true } } }`, once per product.
|
||||
CREATE INDEX IF NOT EXISTS "ProductVariant_productId_isActive_idx" ON "ProductVariant" ("productId", "isActive");
|
||||
|
||||
-- `where: { images: { some: {} } }` plus the gallery ordered by sortOrder.
|
||||
CREATE INDEX IF NOT EXISTS "ProductImage_productId_sortOrder_idx" ON "ProductImage" ("productId", "sortOrder");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,780 @@
|
||||
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();
|
||||
|
||||
// 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).
|
||||
// Replace these rows with real photo URLs when product photography exists.
|
||||
const PLACEHOLDER_PRODUCT_IMAGE = '/img/no-items.png';
|
||||
|
||||
/**
|
||||
* Role definitions and their permissions — the one part of this file a real
|
||||
* deployment needs (the admin screens and the legacy import map onto these
|
||||
* ids). Idempotent: upserts and skipDuplicates only.
|
||||
*/
|
||||
async function seedAccessControl() {
|
||||
// ── 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.view', '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.view',
|
||||
],
|
||||
},
|
||||
{
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything below is DEMO data: sample cities, branches, accounts with known
|
||||
* passwords (one super admin whose password is its own phone number), sample
|
||||
* customers, products, orders and posted debt. The app runs this seed once on
|
||||
* a fresh database at boot (src/prisma/bootstrap.ts), so without this gate a
|
||||
* production first boot would mix fake customers, fake debt and open admin
|
||||
* accounts into the real data (SEC-05). Production never gets it unless
|
||||
* SEED_DEMO_DATA=true is set on purpose.
|
||||
*/
|
||||
function demoDataAllowed(): boolean {
|
||||
if (process.env.SEED_DEMO_DATA?.trim() === 'true') return true;
|
||||
return process.env.NODE_ENV !== 'production';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await seedAccessControl();
|
||||
|
||||
if (!demoDataAllowed()) {
|
||||
console.log('seed: production — roles and permissions only; demo data skipped (set SEED_DEMO_DATA=true to force)');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Cities ────────────────────────────────────────────────────────────
|
||||
// A tree, as legacy's: country > province > city. The country is a heading
|
||||
// in the admin pickers; provinces and cities are selectable.
|
||||
const city = (id: string, name: string, parentId: string | null) =>
|
||||
prisma.city.upsert({ where: { id }, update: {}, create: { id, name, parentId } });
|
||||
await city('city-iran', 'ایران', null);
|
||||
await Promise.all([
|
||||
city('city-prov-tehran', 'استان تهران', 'city-iran'),
|
||||
city('city-prov-khorasan-razavi', 'استان خراسان رضوی', 'city-iran'),
|
||||
city('city-prov-isfahan', 'استان اصفهان', 'city-iran'),
|
||||
city('city-prov-east-azarbaijan', 'استان آذربایجان شرقی', 'city-iran'),
|
||||
]);
|
||||
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
|
||||
city('city-tehran', 'تهران', 'city-prov-tehran'),
|
||||
city('city-mashhad', 'مشهد', 'city-prov-khorasan-razavi'),
|
||||
city('city-isfahan', 'اصفهان', 'city-prov-isfahan'),
|
||||
city('city-tabriz', 'تبریز', 'city-prov-east-azarbaijan'),
|
||||
]);
|
||||
|
||||
// ── Branches & workshop ──────────────────────────────────────────────
|
||||
const branchTehran = await prisma.branch.upsert({
|
||||
where: { id: 'branch-tehran-bazaar' },
|
||||
update: {},
|
||||
create: { id: 'branch-tehran-bazaar', name: 'شعبه بازار بزرگ تهران', cityId: tehran.id },
|
||||
});
|
||||
const branchMashhad = await prisma.branch.upsert({
|
||||
where: { id: 'branch-mashhad-main' },
|
||||
update: {},
|
||||
create: { id: 'branch-mashhad-main', name: 'شعبه مرکزی مشهد', cityId: mashhad.id },
|
||||
});
|
||||
await prisma.workshop.upsert({
|
||||
where: { id: 'workshop-central' },
|
||||
update: {},
|
||||
create: { id: 'workshop-central', name: 'کارگاه مرکزی ساخت طلا' },
|
||||
});
|
||||
|
||||
// ── 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: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
||||
create: {
|
||||
mobile: '09120000001',
|
||||
fullName: 'مدیر ارشد سیستم',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.SUPER_ADMIN,
|
||||
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: { 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: { 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: { 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: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null },
|
||||
create: {
|
||||
mobile: '09125556666',
|
||||
fullName: 'فرزانه محمودی',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.ACCOUNTANT,
|
||||
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',
|
||||
cityId: tehran.id,
|
||||
referredByAgentId: agentReza.id,
|
||||
goldCreditLimitGrams: '200',
|
||||
verificationStep: VerificationStep.VERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-2',
|
||||
kind: CustomerKind.APP_SHOPKEEPER,
|
||||
storeName: 'گالری حسینی', zone: 'ونک', memberCode: 'M-1002', phone: '02188776655',
|
||||
fullName: 'مریم حسینی',
|
||||
mobile: '09151112234',
|
||||
nationalCode: '0012345679',
|
||||
cityId: tehran.id,
|
||||
referredByAgentId: agentReza.id,
|
||||
goldCreditLimitGrams: '100',
|
||||
verificationStep: VerificationStep.VERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-3',
|
||||
kind: CustomerKind.WALK_IN_SHOPKEEPER,
|
||||
storeName: 'زرگری نوروزی', pasazhName: 'پاساژ رضا', zone: 'طرق', memberCode: 'M-1003',
|
||||
fullName: 'جواد نوروزی',
|
||||
mobile: '09151112235',
|
||||
nationalCode: '0012345680',
|
||||
cityId: mashhad.id,
|
||||
referredByAgentId: agentSara.id,
|
||||
goldCreditLimitGrams: '50',
|
||||
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
|
||||
},
|
||||
{
|
||||
id: 'customer-4',
|
||||
kind: CustomerKind.APP_SHOPKEEPER,
|
||||
memberCode: 'M-1004',
|
||||
fullName: 'نرگس ابراهیمی',
|
||||
mobile: '09151112236',
|
||||
nationalCode: '0012345681',
|
||||
cityId: isfahan.id,
|
||||
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',
|
||||
cityId: tehran.id,
|
||||
referredByAgentId: agentReza.id,
|
||||
goldCreditLimitGrams: '500',
|
||||
verificationStep: VerificationStep.VERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-6',
|
||||
kind: CustomerKind.WALK_IN_SHOPKEEPER,
|
||||
storeName: 'طلاسرای قاسمی', zone: 'ائل گلی', memberCode: 'M-1006',
|
||||
fullName: 'زهرا قاسمی',
|
||||
mobile: '09151112238',
|
||||
nationalCode: '0012345683',
|
||||
cityId: tabriz.id,
|
||||
referredByAgentId: agentSara.id,
|
||||
goldCreditLimitGrams: '80',
|
||||
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
|
||||
},
|
||||
];
|
||||
|
||||
const customers: Customer[] = [];
|
||||
for (const c of customerData) {
|
||||
customers.push(
|
||||
await prisma.customer.upsert({
|
||||
where: { id: c.id },
|
||||
// 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,
|
||||
mobile: c.mobile,
|
||||
nationalCode: c.nationalCode,
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
cityId: c.cityId,
|
||||
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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 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([
|
||||
prisma.category.upsert({ where: { id: 'cat-rings' }, update: {}, create: { id: 'cat-rings', title: 'انگشتر' } }),
|
||||
prisma.category.upsert({ where: { id: 'cat-bracelets' }, update: {}, create: { id: 'cat-bracelets', title: 'دستبند' } }),
|
||||
prisma.category.upsert({ where: { id: 'cat-necklaces' }, update: {}, create: { id: 'cat-necklaces', title: 'گردنبند' } }),
|
||||
prisma.category.upsert({ where: { id: 'cat-earrings' }, update: {}, create: { id: 'cat-earrings', title: 'گوشواره' } }),
|
||||
prisma.category.upsert({ where: { id: 'cat-coins' }, update: {}, create: { id: 'cat-coins', title: 'سکه و شمش' } }),
|
||||
]);
|
||||
|
||||
// Migrate any leftover ids from older seeds; Product/Promotion FKs cascade on update.
|
||||
await prisma.brand.updateMany({ where: { id: 'brand-henza' }, data: { id: 'brand-hedena' } });
|
||||
await prisma.brand.updateMany({ where: { id: 'brand-montzar' }, data: { id: 'brand-hedena' } });
|
||||
const brandMain = await prisma.brand.upsert({
|
||||
where: { id: 'brand-hedena' },
|
||||
update: { title: 'هدنا گلد' },
|
||||
create: { id: 'brand-hedena', title: 'هدنا گلد' },
|
||||
});
|
||||
const brandArtisan = await prisma.brand.upsert({
|
||||
where: { id: 'brand-artisan' },
|
||||
update: {},
|
||||
create: { id: 'brand-artisan', title: 'مجموعه صنعتگر' },
|
||||
});
|
||||
|
||||
const productDefs = [
|
||||
{
|
||||
id: 'product-classic-ring',
|
||||
title: 'انگشتر تک نگین کلاسیک',
|
||||
description: 'انگشتر طلای ۱۸ عیار با طراحی ساده و مینیمال.',
|
||||
categoryId: ringsCategory.id,
|
||||
brandId: brandMain.id,
|
||||
variants: [
|
||||
{ id: 'variant-ring-size-6', title: 'سایز ۶', sku: 'RING-CLASSIC-6', rashWeightGrams: '2.850', defaultWagePercent: '9.0' },
|
||||
{ id: 'variant-ring-size-7', title: 'سایز ۷', sku: 'RING-CLASSIC-7', rashWeightGrams: '3.100', defaultWagePercent: '9.0' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product-woven-bracelet',
|
||||
title: 'دستبند بافت زنجیری',
|
||||
description: 'دستبند بافتدار دستساز از طلای ۱۸ عیار.',
|
||||
categoryId: braceletsCategory.id,
|
||||
brandId: brandArtisan.id,
|
||||
variants: [
|
||||
{ id: 'variant-bracelet-standard', title: 'استاندارد ۱۸ سانتی', sku: 'BRC-WOVEN-18', rashWeightGrams: '14.200', defaultWagePercent: '11.5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product-pendant-necklace',
|
||||
title: 'گردنبند آویز اشکی',
|
||||
description: 'گردنبند ظریف با آویز اشکی و زنجیر نازک.',
|
||||
categoryId: necklacesCategory.id,
|
||||
brandId: brandMain.id,
|
||||
variants: [
|
||||
{ id: 'variant-necklace-40cm', title: 'زنجیر ۴۰ سانتی', sku: 'NCK-TEARDROP-40', rashWeightGrams: '5.600', defaultWagePercent: '10.0' },
|
||||
{ id: 'variant-necklace-45cm', title: 'زنجیر ۴۵ سانتی', sku: 'NCK-TEARDROP-45', rashWeightGrams: '6.100', defaultWagePercent: '10.0' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product-hoop-earrings',
|
||||
title: 'گوشواره حلقهای کلاسیک',
|
||||
description: 'گوشواره حلقهای طلای ۱۸ عیار مناسب استفاده روزمره.',
|
||||
categoryId: earringsCategory.id,
|
||||
brandId: brandArtisan.id,
|
||||
variants: [
|
||||
{ id: 'variant-hoop-small', title: 'کوچک', sku: 'ERG-HOOP-S', rashWeightGrams: '1.900', defaultWagePercent: '8.5' },
|
||||
{ id: 'variant-hoop-large', title: 'بزرگ', sku: 'ERG-HOOP-L', rashWeightGrams: '3.400', defaultWagePercent: '8.5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product-full-coin',
|
||||
title: 'سکه تمام بهار آزادی',
|
||||
description: 'سکه طلای تمام بهار آزادی، عیار استاندارد.',
|
||||
categoryId: coinsCategory.id,
|
||||
brandId: brandMain.id,
|
||||
variants: [
|
||||
{ id: 'variant-coin-full', title: 'تمام سکه', sku: 'COIN-FULL', rashWeightGrams: '8.133', defaultWagePercent: '2.0' },
|
||||
{ id: 'variant-coin-half', title: 'نیم سکه', sku: 'COIN-HALF', rashWeightGrams: '4.066', defaultWagePercent: '2.5' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const createdVariantIds: Record<string, string> = {};
|
||||
let imageCount = 0;
|
||||
for (const p of productDefs) {
|
||||
const product = await prisma.product.upsert({
|
||||
where: { id: p.id },
|
||||
update: {},
|
||||
create: {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
categoryId: p.categoryId,
|
||||
brandId: p.brandId,
|
||||
},
|
||||
});
|
||||
for (const v of p.variants) {
|
||||
await prisma.productVariant.upsert({
|
||||
where: { id: v.id },
|
||||
update: {},
|
||||
create: {
|
||||
id: v.id,
|
||||
productId: product.id,
|
||||
title: v.title,
|
||||
sku: v.sku,
|
||||
rashWeightGrams: v.rashWeightGrams,
|
||||
defaultWagePercent: v.defaultWagePercent,
|
||||
},
|
||||
});
|
||||
createdVariantIds[v.id] = v.id;
|
||||
}
|
||||
|
||||
// Product photography does not exist in this project: legacy served it from
|
||||
// the old Laravel host (Product.thumb.full), so there is nothing to migrate.
|
||||
// Seed the placeholder the legacy app itself used for missing imagery so
|
||||
// every product card renders an image instead of collapsing to nothing.
|
||||
await prisma.productImage.upsert({
|
||||
where: { id: `${p.id}-img-1` },
|
||||
update: { url: PLACEHOLDER_PRODUCT_IMAGE },
|
||||
create: {
|
||||
id: `${p.id}-img-1`,
|
||||
productId: product.id,
|
||||
url: PLACEHOLDER_PRODUCT_IMAGE,
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
imageCount += 1;
|
||||
}
|
||||
|
||||
// ── Featured collection ──────────────────────────────────────────────
|
||||
const featured = await prisma.collection.upsert({
|
||||
where: { id: 'collection-featured' },
|
||||
update: {},
|
||||
create: { id: 'collection-featured', title: 'پرفروشهای این هفته', description: 'منتخبی از محبوبترین محصولات فروشگاه.' },
|
||||
});
|
||||
await prisma.collectionProduct.upsert({
|
||||
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-classic-ring' } },
|
||||
update: {},
|
||||
create: { collectionId: featured.id, productId: 'product-classic-ring' },
|
||||
});
|
||||
await prisma.collectionProduct.upsert({
|
||||
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-pendant-necklace' } },
|
||||
update: {},
|
||||
create: { collectionId: featured.id, productId: 'product-pendant-necklace' },
|
||||
});
|
||||
await prisma.collectionProduct.upsert({
|
||||
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-full-coin' } },
|
||||
update: {},
|
||||
create: { collectionId: featured.id, productId: 'product-full-coin' },
|
||||
});
|
||||
|
||||
// ── Gold price quote (illustrative, not live-market-accurate) ───────
|
||||
await prisma.goldPriceQuote.create({
|
||||
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) {
|
||||
const rashWeight = '3.100'; // variant-ring-size-7
|
||||
const wagePercent = 9.0;
|
||||
const discountPercent = 0;
|
||||
const computedWeight = (parseFloat(rashWeight) * (1 + (wagePercent - discountPercent) / 100)).toFixed(4);
|
||||
const quantity = 1;
|
||||
const flatRialChargePerUnit = '500000'; // e.g. a small stone-setting fee
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
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),
|
||||
flatRialCharge: flatRialChargePerUnit,
|
||||
deliveredAt: new Date(),
|
||||
items: {
|
||||
create: [
|
||||
{
|
||||
productVariantId: 'variant-ring-size-7',
|
||||
quantity,
|
||||
rashWeightGramsSnapshot: rashWeight,
|
||||
wagePercentSnapshot: wagePercent.toString(),
|
||||
discountPercentSnapshot: discountPercent.toString(),
|
||||
computedWeightGrams: computedWeight,
|
||||
flatRialCharge: flatRialChargePerUnit,
|
||||
},
|
||||
],
|
||||
},
|
||||
statusEvents: {
|
||||
create: [{ toStatus: 'PLACED' }, { toStatus: 'ADMIN_APPROVED' }, { toStatus: 'DELIVERED_TO_CUSTOMER' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.ledgerEntry.create({
|
||||
data: {
|
||||
customerId: customer1.id,
|
||||
currency: 'GOLD_GRAM',
|
||||
direction: 'DEBIT',
|
||||
sourceType: 'ORDER_STOCK',
|
||||
amount: computedWeight,
|
||||
orderId: order.id,
|
||||
confirmed: true,
|
||||
confirmedAt: new Date(),
|
||||
note: 'بدهی وزنی طلا هنگام تحویل (داده آزمایشی)',
|
||||
},
|
||||
});
|
||||
await prisma.ledgerEntry.create({
|
||||
data: {
|
||||
customerId: customer1.id,
|
||||
currency: 'RIAL',
|
||||
direction: 'DEBIT',
|
||||
sourceType: 'ORDER_STOCK',
|
||||
amount: flatRialChargePerUnit,
|
||||
orderId: order.id,
|
||||
confirmed: true,
|
||||
confirmedAt: new Date(),
|
||||
note: 'هزینه ثابت ریالی هنگام تحویل (داده آزمایشی)',
|
||||
},
|
||||
});
|
||||
await prisma.customer.update({
|
||||
where: { id: customer1.id },
|
||||
data: { goldDebtGrams: { increment: computedWeight }, fiatDebtRial: { increment: flatRialChargePerUnit } },
|
||||
});
|
||||
|
||||
// A pending repayment awaiting admin confirmation, to demo the confirm flow.
|
||||
await prisma.ledgerEntry.create({
|
||||
data: {
|
||||
customerId: customer1.id,
|
||||
currency: 'GOLD_GRAM',
|
||||
direction: 'CREDIT',
|
||||
sourceType: 'REPAYMENT_MELTED_GOLD',
|
||||
amount: '1.5000',
|
||||
karatPerMille: 750,
|
||||
confirmed: false,
|
||||
note: 'تسویه با طلای آبشده، در انتظار تایید (داده آزمایشی)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// customer2 and customer5 get existing debt/credit variety for the dashboard.
|
||||
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,
|
||||
adminUsers: 5,
|
||||
customers: customers.length,
|
||||
products: productDefs.length,
|
||||
variants: Object.keys(createdVariantIds).length,
|
||||
productImages: imageCount,
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user