feat(products): seed 24 sku items with structured specs

This commit is contained in:
Ruslan Bakiev
2026-04-03 10:40:29 +07:00
parent f7fb45618d
commit 7cd650bd04
3 changed files with 96 additions and 101 deletions

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "lengthM" INTEGER,
ADD COLUMN "productType" TEXT,
ADD COLUMN "quantityPerBox" TEXT,
ADD COLUMN "sleeveBrand" TEXT,
ADD COLUMN "thicknessMicron" INTEGER,
ADD COLUMN "widthMm" INTEGER;

View File

@@ -165,6 +165,12 @@ model Product {
id String @id @default(cuid())
sku String @unique
name String
productType String?
widthMm Int?
lengthM Int?
thicknessMicron Int?
sleeveBrand String?
quantityPerBox String?
description String?
isCustomizable Boolean @default(false)
isActive Boolean @default(true)

View File

@@ -2,100 +2,62 @@ import 'dotenv/config';
import { prisma } from '../src/prisma-client.js';
const CATALOG_PAGES = ['https://fregat.org/catalog/', 'https://fregat.org/catalog/page/2/'];
const managerEmail = 'manager@fregat.local';
const clientEmail = 'client@fregat.local';
function decodeHtmlEntities(value) {
return value
.replaceAll(' ', ' ')
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll(''', "'")
.replaceAll('«', '«')
.replaceAll('»', '»')
.replaceAll('—', '—')
.replaceAll('–', '-');
const PRODUCT_TYPES = {
STANDARD: 'стандартная лента',
PAINTING: 'малярный скотч',
};
const PRODUCT_ROWS = [
{ sku: '480200', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 40, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '72' },
{ sku: '481200', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 55, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '72/36' },
{ sku: '482200', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 66, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '36' },
{ sku: '483200', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 120, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '36' },
{ sku: '751200', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 55, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '48' },
{ sku: '752200', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 66, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '24' },
{ sku: '753200', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 120, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '24' },
{ sku: '481400', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 55, thicknessMicron: 43, sleeveBrand: 'фрегат', quantityPerBox: '72/36' },
{ sku: '482400', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 66, thicknessMicron: 43, sleeveBrand: 'фрегат', quantityPerBox: '36' },
{ sku: '483400', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 120, thicknessMicron: 43, sleeveBrand: 'фрегат', quantityPerBox: '36' },
{ sku: '751400', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 55, thicknessMicron: 43, sleeveBrand: 'фрегат', quantityPerBox: '48' },
{ sku: '752400', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 66, thicknessMicron: 43, sleeveBrand: 'фрегат', quantityPerBox: '24' },
{ sku: '753400', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 120, thicknessMicron: 43, sleeveBrand: 'фрегат', quantityPerBox: '24' },
{ sku: '481500', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 55, thicknessMicron: 45, sleeveBrand: 'фрегат', quantityPerBox: '72/36' },
{ sku: '482500', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 66, thicknessMicron: 45, sleeveBrand: 'фрегат', quantityPerBox: '36' },
{ sku: '483500', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 120, thicknessMicron: 45, sleeveBrand: 'фрегат', quantityPerBox: '36' },
{ sku: '487500', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 150, thicknessMicron: 45, sleeveBrand: 'фрегат', quantityPerBox: '36' },
{ sku: '751500', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 55, thicknessMicron: 45, sleeveBrand: 'фрегат', quantityPerBox: '48' },
{ sku: '752500', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 66, thicknessMicron: 45, sleeveBrand: 'фрегат', quantityPerBox: '24' },
{ sku: '743500', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 120, thicknessMicron: 45, sleeveBrand: 'фрегат', quantityPerBox: '24' },
{ sku: '482600', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 66, thicknessMicron: 47, sleeveBrand: 'фрегат', quantityPerBox: '36' },
{ sku: '483600', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 120, thicknessMicron: 47, sleeveBrand: 'фрегат', quantityPerBox: '36' },
{ sku: '752600', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 66, thicknessMicron: 47, sleeveBrand: 'фрегат', quantityPerBox: '24' },
{ sku: '753600', productType: PRODUCT_TYPES.STANDARD, widthMm: 75, lengthM: 120, thicknessMicron: 47, sleeveBrand: 'фрегат', quantityPerBox: '24' },
];
function sentenceCase(value) {
if (!value) {
return value;
}
return value[0].toUpperCase() + value.slice(1);
}
function normalizeText(value) {
return decodeHtmlEntities(value.replaceAll(/<[^>]*>/g, ' ').replaceAll(/\s+/g, ' ')).trim();
function buildName(product) {
return [
sentenceCase(product.productType),
`${product.widthMm}x${product.lengthM} м`,
`${product.thicknessMicron} мкм`,
`втулка ${sentenceCase(product.sleeveBrand)}`,
`короб ${product.quantityPerBox}`,
].join(', ');
}
function skuFromHref(href, index) {
const pathname = href.replace(/^https?:\/\/[^/]+/i, '').split('?')[0] || '';
const segments = pathname.split('/').filter(Boolean);
const slug = segments[segments.length - 1] ?? '';
if (!slug) {
return `FREGAT-CATALOG-${String(index + 1).padStart(3, '0')}`;
}
const normalized = slug.toUpperCase().replaceAll(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return `FREGAT-${normalized || String(index + 1).padStart(3, '0')}`;
}
function parseCatalogCards(html) {
const cards = [];
const cardRegex = /<a([^>]*)class="[^"]*product-card[^"]*"([^>]*)>([\s\S]*?)<\/a>/g;
let match;
while ((match = cardRegex.exec(html)) !== null) {
const attrs = `${match[1]} ${match[2]}`;
const body = match[3];
const hrefMatch = attrs.match(/href="([^"]+)"/);
const titleMatch = body.match(/<div class="product-card__title">([\s\S]*?)<\/div>/);
const typeMatch = body.match(/<div class="product-card__type">([\s\S]*?)<\/div>/);
const href = hrefMatch?.[1] ?? '';
const title = normalizeText(titleMatch?.[1] ?? '');
const type = normalizeText(typeMatch?.[1] ?? '');
if (!title) {
continue;
}
cards.push({ href, title, type });
}
return cards;
}
async function loadCatalogProducts() {
const parsed = [];
for (const pageUrl of CATALOG_PAGES) {
const response = await fetch(pageUrl, { redirect: 'follow' });
if (!response.ok) {
throw new Error(`Catalog request failed: ${pageUrl} (${response.status})`);
}
const html = await response.text();
const cards = parseCatalogCards(html);
if (!cards.length) {
throw new Error(`No product cards found at ${pageUrl}`);
}
parsed.push(...cards);
}
const deduped = new Map();
for (const [index, card] of parsed.entries()) {
const sku = skuFromHref(card.href, index);
if (deduped.has(sku)) {
continue;
}
const descriptionParts = [];
if (card.type) {
descriptionParts.push(card.type);
}
descriptionParts.push(`Источник: ${card.href}`);
deduped.set(sku, {
sku,
name: card.title,
description: descriptionParts.join('. '),
isCustomizable: /логотип|печать/i.test(`${card.title} ${card.type}`),
qtyMain: 300 + index * 17,
qtyReserve: 120 + index * 9,
});
}
return [...deduped.values()];
function baseQuantity(quantityPerBox) {
const firstValue = quantityPerBox.split('/')[0];
const parsed = Number.parseInt(firstValue, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 24;
}
const company = await prisma.company.upsert({
@@ -140,23 +102,42 @@ const warehouseReserve = await prisma.warehouse.upsert({
create: { code: 'SPB-01', name: 'Reserve warehouse' },
});
const products = await loadCatalogProducts();
console.log(`Loaded ${products.length} products from fregat.org catalog.`);
const activeSkus = PRODUCT_ROWS.map((item) => item.sku);
await prisma.product.updateMany({
where: { sku: { notIn: activeSkus } },
data: { isActive: false },
});
for (const product of PRODUCT_ROWS) {
const qtyBase = baseQuantity(product.quantityPerBox);
const qtyMain = qtyBase * 10;
const qtyReserve = qtyBase * 5;
for (const product of products) {
const dbProduct = await prisma.product.upsert({
where: { sku: product.sku },
update: {
name: product.name,
description: product.description,
isCustomizable: product.isCustomizable,
name: buildName(product),
description: `Артикул: ${product.sku}`,
productType: product.productType,
widthMm: product.widthMm,
lengthM: product.lengthM,
thicknessMicron: product.thicknessMicron,
sleeveBrand: product.sleeveBrand,
quantityPerBox: product.quantityPerBox,
isCustomizable: false,
isActive: true,
},
create: {
sku: product.sku,
name: product.name,
description: product.description,
isCustomizable: product.isCustomizable,
name: buildName(product),
description: `Артикул: ${product.sku}`,
productType: product.productType,
widthMm: product.widthMm,
lengthM: product.lengthM,
thicknessMicron: product.thicknessMicron,
sleeveBrand: product.sleeveBrand,
quantityPerBox: product.quantityPerBox,
isCustomizable: false,
isActive: true,
},
});
@@ -168,11 +149,11 @@ for (const product of products) {
warehouseId: warehouseMain.id,
},
},
update: { availableQty: product.qtyMain },
update: { availableQty: qtyMain },
create: {
productId: dbProduct.id,
warehouseId: warehouseMain.id,
availableQty: product.qtyMain,
availableQty: qtyMain,
},
});
@@ -183,14 +164,14 @@ for (const product of products) {
warehouseId: warehouseReserve.id,
},
},
update: { availableQty: product.qtyReserve },
update: { availableQty: qtyReserve },
create: {
productId: dbProduct.id,
warehouseId: warehouseReserve.id,
availableQty: product.qtyReserve,
availableQty: qtyReserve,
},
});
}
console.log('Seed complete. Use manager header x-user-id:', manager.id);
console.log(`Seed complete with ${PRODUCT_ROWS.length} products. Use manager header x-user-id: ${manager.id}`);
await prisma.$disconnect();