feat(products): seed 24 sku items with structured specs
This commit is contained in:
183
scripts/seed.js
183
scripts/seed.js
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user