Stage M1
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import {
|
||||
BRANDS,
|
||||
CATEGORIES,
|
||||
COLLECTIONS,
|
||||
COLOURS,
|
||||
PRODUCTS,
|
||||
type Localised,
|
||||
type ProductSeed,
|
||||
} from './data';
|
||||
import { blurDataUrl, hexToRgb, solidPng } from './png';
|
||||
|
||||
/**
|
||||
* Demo catalog seed. Development and staging only — never production.
|
||||
*
|
||||
* Idempotent by natural key (slug / SKU / path), so re-running updates rather
|
||||
* than duplicating. Deterministic throughout: same input, same stock numbers,
|
||||
* same images. A seed that produces different data on each run makes "is this
|
||||
* a bug or just the seed?" unanswerable.
|
||||
*/
|
||||
|
||||
const IMAGE_WIDTH = 1200;
|
||||
const IMAGE_HEIGHT = 1500;
|
||||
|
||||
type Locale = 'VI' | 'EN';
|
||||
const LOCALES: Locale[] = ['VI', 'EN'];
|
||||
|
||||
function localeValue(value: Localised, locale: Locale): string {
|
||||
return locale === 'VI' ? value.vi : value.en;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic PRNG (mulberry32) seeded from a string. Stock levels vary
|
||||
* across variants — as they do in reality — but never between runs.
|
||||
*/
|
||||
function seededRandom(seed: string): () => number {
|
||||
let h = 1779033703 ^ seed.length;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
|
||||
let state = h >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedCatalog(prisma: PrismaClient): Promise<void> {
|
||||
const storage = createStorageClient();
|
||||
|
||||
const brandIds = await seedBrands(prisma);
|
||||
const categoryIds = await seedCategories(prisma);
|
||||
const collectionIds = await seedCollections(prisma);
|
||||
const location = await prisma.inventoryLocation.findUniqueOrThrow({ where: { code: 'MAIN' } });
|
||||
|
||||
for (const product of PRODUCTS) {
|
||||
await seedProduct(prisma, storage, product, {
|
||||
brandId: brandIds.get(product.brand),
|
||||
categoryId: categoryIds.get(product.categoryPath),
|
||||
collectionIds,
|
||||
locationId: location.id,
|
||||
});
|
||||
}
|
||||
|
||||
const dropped = await invalidateCatalogCache();
|
||||
|
||||
console.log(
|
||||
`Catalog: ${BRANDS.length} brands, ${CATEGORIES.length} categories, ` +
|
||||
`${COLLECTIONS.length} collections, ${PRODUCTS.length} products` +
|
||||
`${dropped === null ? '' : `, ${dropped} cache keys dropped`}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the catalog read cache.
|
||||
*
|
||||
* Without this, re-seeding leaves Redis serving the previous catalog for up to
|
||||
* 15 minutes — which reads as "my changes did nothing" and burns an afternoon.
|
||||
* Failure is non-fatal: an unreachable cache must never fail a seed.
|
||||
*/
|
||||
async function invalidateCatalogCache(): Promise<number | null> {
|
||||
const url = process.env['REDIS_URL'];
|
||||
if (!url) return null;
|
||||
|
||||
const prefix = process.env['REDIS_KEY_PREFIX'] ?? 'sport:';
|
||||
const redis = new Redis(url, { maxRetriesPerRequest: 1, lazyConnect: true });
|
||||
|
||||
try {
|
||||
await redis.connect();
|
||||
|
||||
// SCAN, never KEYS: KEYS blocks the whole server, and this same helper will
|
||||
// eventually run against production during a catalog import.
|
||||
let cursor = '0';
|
||||
let removed = 0;
|
||||
|
||||
do {
|
||||
const [next, keys] = await redis.scan(cursor, 'MATCH', `${prefix}catalog:*`, 'COUNT', 500);
|
||||
cursor = next;
|
||||
if (keys.length > 0) {
|
||||
// Keys from SCAN already carry the prefix; `del` must not re-add it.
|
||||
removed += await redis.del(...keys);
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
return removed;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStorageClient(): S3Client {
|
||||
return new S3Client({
|
||||
endpoint: process.env['STORAGE_ENDPOINT'] ?? 'http://localhost:9000',
|
||||
region: process.env['STORAGE_REGION'] ?? 'auto',
|
||||
forcePathStyle: (process.env['STORAGE_FORCE_PATH_STYLE'] ?? 'true') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: process.env['STORAGE_ACCESS_KEY_ID'] ?? 'sportminio',
|
||||
secretAccessKey: process.env['STORAGE_SECRET_ACCESS_KEY'] ?? 'sportminio',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a generated placeholder and records the metadata row.
|
||||
*
|
||||
* Bytes to object storage, key to PostgreSQL — the same split real uploads use
|
||||
* (ADR-0009). The key is derived from the colourway so re-running overwrites
|
||||
* the same object instead of littering the bucket.
|
||||
*/
|
||||
async function upsertMedia(
|
||||
prisma: PrismaClient,
|
||||
storage: S3Client,
|
||||
storageKey: string,
|
||||
hex: string,
|
||||
altText: string,
|
||||
): Promise<string> {
|
||||
const rgb = hexToRgb(hex);
|
||||
const png = solidPng(IMAGE_WIDTH, IMAGE_HEIGHT, rgb);
|
||||
|
||||
await storage.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env['STORAGE_BUCKET'] ?? 'sport-media',
|
||||
Key: storageKey,
|
||||
Body: png,
|
||||
ContentType: 'image/png',
|
||||
CacheControl: 'public, max-age=31536000, immutable',
|
||||
}),
|
||||
);
|
||||
|
||||
const media = await prisma.mediaAsset.upsert({
|
||||
where: { storageKey },
|
||||
update: { altText, sizeBytes: png.length },
|
||||
create: {
|
||||
kind: 'IMAGE',
|
||||
storageKey,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: png.length,
|
||||
width: IMAGE_WIDTH,
|
||||
height: IMAGE_HEIGHT,
|
||||
blurDataUrl: blurDataUrl(rgb),
|
||||
altText,
|
||||
},
|
||||
});
|
||||
|
||||
return media.id;
|
||||
}
|
||||
|
||||
async function seedBrands(prisma: PrismaClient): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
|
||||
for (const brand of BRANDS) {
|
||||
const row = await prisma.brand.upsert({
|
||||
where: { slug: brand.key },
|
||||
update: { name: brand.name.en, description: brand.description.en },
|
||||
create: { slug: brand.key, name: brand.name.en, description: brand.description.en },
|
||||
});
|
||||
ids.set(brand.key, row.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.brandTranslation.upsert({
|
||||
where: { brandId_locale: { brandId: row.id, locale } },
|
||||
update: {
|
||||
name: localeValue(brand.name, locale),
|
||||
slug: localeValue(brand.slug, locale),
|
||||
description: localeValue(brand.description, locale),
|
||||
},
|
||||
create: {
|
||||
brandId: row.id,
|
||||
locale,
|
||||
name: localeValue(brand.name, locale),
|
||||
slug: localeValue(brand.slug, locale),
|
||||
description: localeValue(brand.description, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function seedCategories(prisma: PrismaClient): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
|
||||
// CATEGORIES is ordered parents-first, so a parent id is always known by the
|
||||
// time its children are processed.
|
||||
for (const category of CATEGORIES) {
|
||||
const depth = category.path.split('/').length - 1;
|
||||
const parentId = category.parentPath ? ids.get(category.parentPath) : null;
|
||||
|
||||
const row = await prisma.category.upsert({
|
||||
where: { path: category.path },
|
||||
update: { name: category.name.en, position: category.position, parentId, depth },
|
||||
create: {
|
||||
path: category.path,
|
||||
slug: category.slug.en,
|
||||
name: category.name.en,
|
||||
position: category.position,
|
||||
parentId,
|
||||
depth,
|
||||
},
|
||||
});
|
||||
ids.set(category.path, row.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.categoryTranslation.upsert({
|
||||
where: { categoryId_locale: { categoryId: row.id, locale } },
|
||||
update: {
|
||||
name: localeValue(category.name, locale),
|
||||
slug: localeValue(category.slug, locale),
|
||||
},
|
||||
create: {
|
||||
categoryId: row.id,
|
||||
locale,
|
||||
name: localeValue(category.name, locale),
|
||||
slug: localeValue(category.slug, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function seedCollections(prisma: PrismaClient): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
|
||||
for (const collection of COLLECTIONS) {
|
||||
const row = await prisma.collection.upsert({
|
||||
where: { slug: collection.key },
|
||||
update: { name: collection.name.en, position: collection.position },
|
||||
create: {
|
||||
slug: collection.key,
|
||||
name: collection.name.en,
|
||||
description: collection.description.en,
|
||||
position: collection.position,
|
||||
type: 'MANUAL',
|
||||
},
|
||||
});
|
||||
ids.set(collection.key, row.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.collectionTranslation.upsert({
|
||||
where: { collectionId_locale: { collectionId: row.id, locale } },
|
||||
update: {
|
||||
name: localeValue(collection.name, locale),
|
||||
slug: localeValue(collection.slug, locale),
|
||||
description: localeValue(collection.description, locale),
|
||||
},
|
||||
create: {
|
||||
collectionId: row.id,
|
||||
locale,
|
||||
name: localeValue(collection.name, locale),
|
||||
slug: localeValue(collection.slug, locale),
|
||||
description: localeValue(collection.description, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function seedProduct(
|
||||
prisma: PrismaClient,
|
||||
storage: S3Client,
|
||||
seed: ProductSeed,
|
||||
refs: {
|
||||
brandId: string | undefined;
|
||||
categoryId: string | undefined;
|
||||
collectionIds: Map<string, string>;
|
||||
locationId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const product = await prisma.product.upsert({
|
||||
where: { slug: seed.key },
|
||||
update: {
|
||||
name: seed.name.en,
|
||||
status: 'ACTIVE',
|
||||
publishedAt: new Date('2026-01-15T00:00:00Z'),
|
||||
brandId: refs.brandId ?? null,
|
||||
primaryCategoryId: refs.categoryId ?? null,
|
||||
genderTargets: seed.genders,
|
||||
sportTypes: seed.sports,
|
||||
},
|
||||
create: {
|
||||
slug: seed.key,
|
||||
name: seed.name.en,
|
||||
description: seed.description.en,
|
||||
shortDescription: seed.shortDescription.en,
|
||||
status: 'ACTIVE',
|
||||
publishedAt: new Date('2026-01-15T00:00:00Z'),
|
||||
brandId: refs.brandId ?? null,
|
||||
primaryCategoryId: refs.categoryId ?? null,
|
||||
genderTargets: seed.genders,
|
||||
sportTypes: seed.sports,
|
||||
},
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
// An empty string in the seed means "not translated" — store null so the
|
||||
// API's field-level fallback kicks in rather than rendering a blank.
|
||||
const description = localeValue(seed.description, locale) || null;
|
||||
|
||||
await prisma.productTranslation.upsert({
|
||||
where: { productId_locale: { productId: product.id, locale } },
|
||||
update: {
|
||||
name: localeValue(seed.name, locale),
|
||||
slug: localeValue(seed.slug, locale),
|
||||
shortDescription: localeValue(seed.shortDescription, locale),
|
||||
description,
|
||||
},
|
||||
create: {
|
||||
productId: product.id,
|
||||
locale,
|
||||
name: localeValue(seed.name, locale),
|
||||
slug: localeValue(seed.slug, locale),
|
||||
shortDescription: localeValue(seed.shortDescription, locale),
|
||||
description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await seedAttributes(prisma, product.id, seed);
|
||||
await prisma.productCollection.deleteMany({ where: { productId: product.id } });
|
||||
for (const key of seed.collections) {
|
||||
const collectionId = refs.collectionIds.get(key);
|
||||
if (collectionId) {
|
||||
await prisma.productCollection.create({ data: { productId: product.id, collectionId } });
|
||||
}
|
||||
}
|
||||
|
||||
const { colourValueIds, sizeValueIds, colourOptionId, sizeOptionId } = await seedOptions(
|
||||
prisma,
|
||||
product.id,
|
||||
seed,
|
||||
);
|
||||
|
||||
await seedImages(prisma, storage, product.id, seed, colourValueIds);
|
||||
await seedVariants(prisma, product.id, seed, {
|
||||
colourOptionId,
|
||||
sizeOptionId,
|
||||
colourValueIds,
|
||||
sizeValueIds,
|
||||
locationId: refs.locationId,
|
||||
});
|
||||
|
||||
await recomputePricing(prisma, product.id);
|
||||
}
|
||||
|
||||
async function seedAttributes(
|
||||
prisma: PrismaClient,
|
||||
productId: string,
|
||||
seed: ProductSeed,
|
||||
): Promise<void> {
|
||||
for (const [index, attribute] of seed.attributes.entries()) {
|
||||
const row = await prisma.productAttribute.upsert({
|
||||
where: { productId_key: { productId, key: attribute.key } },
|
||||
update: { label: attribute.label.en, value: attribute.value.en, position: index },
|
||||
create: {
|
||||
productId,
|
||||
key: attribute.key,
|
||||
label: attribute.label.en,
|
||||
value: attribute.value.en,
|
||||
position: index,
|
||||
},
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.productAttributeTranslation.upsert({
|
||||
where: { attributeId_locale: { attributeId: row.id, locale } },
|
||||
update: {
|
||||
label: localeValue(attribute.label, locale),
|
||||
value: localeValue(attribute.value, locale),
|
||||
},
|
||||
create: {
|
||||
attributeId: row.id,
|
||||
locale,
|
||||
label: localeValue(attribute.label, locale),
|
||||
value: localeValue(attribute.value, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Colour and Size axes and their values.
|
||||
*
|
||||
* This is the shape the whole catalog model rests on: options define the axes,
|
||||
* values define the allowed points, and the variant table below resolves every
|
||||
* combination into a sellable unit.
|
||||
*/
|
||||
async function seedOptions(prisma: PrismaClient, productId: string, seed: ProductSeed) {
|
||||
const colourOption = await prisma.productOption.upsert({
|
||||
where: { productId_key: { productId, key: 'colour' } },
|
||||
update: { name: 'Colour', position: 0 },
|
||||
create: { productId, key: 'colour', name: 'Colour', position: 0 },
|
||||
});
|
||||
|
||||
const sizeOption = await prisma.productOption.upsert({
|
||||
where: { productId_key: { productId, key: 'size' } },
|
||||
update: { name: 'Size', position: 1 },
|
||||
create: { productId, key: 'size', name: 'Size', position: 1 },
|
||||
});
|
||||
|
||||
for (const [optionId, names] of [
|
||||
[colourOption.id, { vi: 'Màu sắc', en: 'Colour' }],
|
||||
[sizeOption.id, { vi: 'Kích cỡ', en: 'Size' }],
|
||||
] as const) {
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.productOptionTranslation.upsert({
|
||||
where: { optionId_locale: { optionId, locale } },
|
||||
update: { name: localeValue(names, locale) },
|
||||
create: { optionId, locale, name: localeValue(names, locale) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const colourValueIds = new Map<string, string>();
|
||||
for (const [index, colourKey] of seed.colours.entries()) {
|
||||
const colour = COLOURS[colourKey];
|
||||
if (!colour) continue;
|
||||
|
||||
const value = await prisma.productOptionValue.upsert({
|
||||
where: { optionId_value: { optionId: colourOption.id, value: colour.value } },
|
||||
update: { label: colour.label.en, position: index, swatchHex: colour.hex },
|
||||
create: {
|
||||
optionId: colourOption.id,
|
||||
value: colour.value,
|
||||
label: colour.label.en,
|
||||
position: index,
|
||||
swatchHex: colour.hex,
|
||||
},
|
||||
});
|
||||
colourValueIds.set(colour.value, value.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.productOptionValueTranslation.upsert({
|
||||
where: { optionValueId_locale: { optionValueId: value.id, locale } },
|
||||
update: { label: localeValue(colour.label, locale) },
|
||||
create: { optionValueId: value.id, locale, label: localeValue(colour.label, locale) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes get NO translation rows on purpose: "M" is "M" in both languages.
|
||||
// The API falls back to the base label, which is exactly the intended
|
||||
// behaviour and keeps this from becoming busywork for every new product.
|
||||
const sizeValueIds = new Map<string, string>();
|
||||
for (const [index, size] of seed.sizes.entries()) {
|
||||
const value = await prisma.productOptionValue.upsert({
|
||||
where: { optionId_value: { optionId: sizeOption.id, value: size.toLowerCase() } },
|
||||
update: { label: size, position: index },
|
||||
create: { optionId: sizeOption.id, value: size.toLowerCase(), label: size, position: index },
|
||||
});
|
||||
sizeValueIds.set(size, value.id);
|
||||
}
|
||||
|
||||
return {
|
||||
colourOptionId: colourOption.id,
|
||||
sizeOptionId: sizeOption.id,
|
||||
colourValueIds,
|
||||
sizeValueIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedImages(
|
||||
prisma: PrismaClient,
|
||||
storage: S3Client,
|
||||
productId: string,
|
||||
seed: ProductSeed,
|
||||
colourValueIds: Map<string, string>,
|
||||
): Promise<void> {
|
||||
for (const [index, colourKey] of seed.colours.entries()) {
|
||||
const colour = COLOURS[colourKey];
|
||||
const optionValueId = colour ? colourValueIds.get(colour.value) : undefined;
|
||||
if (!colour || !optionValueId) continue;
|
||||
|
||||
// Two images per colourway: the card's default and its hover state.
|
||||
for (const [variantIndex, suffix] of ['a', 'b'].entries()) {
|
||||
const storageKey = `products/seed/${seed.key}-${colour.value}-${suffix}.png`;
|
||||
const mediaId = await upsertMedia(
|
||||
prisma,
|
||||
storage,
|
||||
storageKey,
|
||||
variantIndex === 0 ? colour.hex : shade(colour.hex),
|
||||
`${seed.name.en} — ${colour.label.en}`,
|
||||
);
|
||||
|
||||
await prisma.productImage.upsert({
|
||||
where: { productId_mediaId_optionValueId: { productId, mediaId, optionValueId } },
|
||||
update: { position: index * 2 + variantIndex },
|
||||
create: { productId, mediaId, optionValueId, position: index * 2 + variantIndex },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Slightly darker sibling colour, so the hover image is visibly different. */
|
||||
function shade(hex: string): string {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
const darken = (value: number) => Math.max(0, Math.round(value * 0.78));
|
||||
return `#${[darken(r), darken(g), darken(b)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heart of the model: one variant per colour × size, each with its own SKU,
|
||||
* price and stock row.
|
||||
*/
|
||||
async function seedVariants(
|
||||
prisma: PrismaClient,
|
||||
productId: string,
|
||||
seed: ProductSeed,
|
||||
refs: {
|
||||
colourOptionId: string;
|
||||
sizeOptionId: string;
|
||||
colourValueIds: Map<string, string>;
|
||||
sizeValueIds: Map<string, string>;
|
||||
locationId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const random = seededRandom(seed.key);
|
||||
let position = 0;
|
||||
|
||||
for (const colourKey of seed.colours) {
|
||||
const colour = COLOURS[colourKey];
|
||||
const colourValueId = colour ? refs.colourValueIds.get(colour.value) : undefined;
|
||||
if (!colour || !colourValueId) continue;
|
||||
|
||||
for (const size of seed.sizes) {
|
||||
const sizeValueId = refs.sizeValueIds.get(size);
|
||||
if (!sizeValueId) continue;
|
||||
|
||||
const sku = `${seed.skuPrefix}-${colour.value.toUpperCase()}-${size.toUpperCase()}`;
|
||||
|
||||
const variant = await prisma.productVariant.upsert({
|
||||
where: { sku },
|
||||
update: {
|
||||
title: `${colour.label.en} / ${size}`,
|
||||
priceAmount: seed.price,
|
||||
salePriceAmount: seed.salePrice ?? null,
|
||||
compareAtAmount: seed.compareAt ?? null,
|
||||
position,
|
||||
},
|
||||
create: {
|
||||
productId,
|
||||
sku,
|
||||
title: `${colour.label.en} / ${size}`,
|
||||
currency: 'VND',
|
||||
priceAmount: seed.price,
|
||||
salePriceAmount: seed.salePrice ?? null,
|
||||
compareAtAmount: seed.compareAt ?? null,
|
||||
weightGrams: seed.weightGrams,
|
||||
position,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
// Composite PK (variantId, optionId) makes "two colours on one variant"
|
||||
// impossible at the database level.
|
||||
for (const [optionId, optionValueId] of [
|
||||
[refs.colourOptionId, colourValueId],
|
||||
[refs.sizeOptionId, sizeValueId],
|
||||
] as const) {
|
||||
await prisma.productVariantOptionValue.upsert({
|
||||
where: { variantId_optionId: { variantId: variant.id, optionId } },
|
||||
update: { optionValueId },
|
||||
create: { variantId: variant.id, optionId, optionValueId },
|
||||
});
|
||||
}
|
||||
|
||||
// A realistic spread: mostly healthy, some low, a few genuinely sold out
|
||||
// so the out-of-stock UI is exercised without being contrived.
|
||||
const roll = random();
|
||||
const onHand =
|
||||
roll < 0.08 ? 0 : roll < 0.2 ? 1 + Math.floor(random() * 4) : 8 + Math.floor(random() * 40);
|
||||
|
||||
await prisma.stockLevel.upsert({
|
||||
where: { variantId_locationId: { variantId: variant.id, locationId: refs.locationId } },
|
||||
update: { onHand },
|
||||
create: { variantId: variant.id, locationId: refs.locationId, onHand, reserved: 0 },
|
||||
});
|
||||
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors ProductsRepository.recomputePricing.
|
||||
*
|
||||
* Duplicated here rather than imported because the seed runs through `tsx`
|
||||
* outside the Nest container — instantiating the DI graph to compute four
|
||||
* columns would be the more fragile choice.
|
||||
*/
|
||||
async function recomputePricing(prisma: PrismaClient, productId: string): Promise<void> {
|
||||
const variants = await prisma.productVariant.findMany({
|
||||
where: { productId, status: 'ACTIVE', deletedAt: null },
|
||||
select: {
|
||||
priceAmount: true,
|
||||
salePriceAmount: true,
|
||||
stockLevels: { select: { onHand: true, reserved: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (variants.length === 0) return;
|
||||
|
||||
const effective = variants.map((v) => v.salePriceAmount ?? v.priceAmount);
|
||||
|
||||
await prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: {
|
||||
minPriceAmount: Math.min(...effective),
|
||||
maxPriceAmount: Math.max(...effective),
|
||||
isOnSale: variants.some((v) => v.salePriceAmount !== null),
|
||||
inStock: variants.some((v) => v.stockLevels.some((l) => l.onHand - l.reserved > 0)),
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user