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()) id String @id @default(cuid())
sku String @unique sku String @unique
name String name String
productType String?
widthMm Int?
lengthM Int?
thicknessMicron Int?
sleeveBrand String?
quantityPerBox String?
description String? description String?
isCustomizable Boolean @default(false) isCustomizable Boolean @default(false)
isActive Boolean @default(true) isActive Boolean @default(true)

View File

@@ -2,100 +2,62 @@ import 'dotenv/config';
import { prisma } from '../src/prisma-client.js'; 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 managerEmail = 'manager@fregat.local';
const clientEmail = 'client@fregat.local'; const clientEmail = 'client@fregat.local';
function decodeHtmlEntities(value) { const PRODUCT_TYPES = {
return value STANDARD: 'стандартная лента',
.replaceAll(' ', ' ') PAINTING: 'малярный скотч',
.replaceAll('&', '&') };
.replaceAll('"', '"')
.replaceAll(''', "'") const PRODUCT_ROWS = [
.replaceAll('«', '«') { sku: '480200', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 40, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '72' },
.replaceAll('»', '»') { sku: '481200', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 55, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '72/36' },
.replaceAll('—', '—') { sku: '482200', productType: PRODUCT_TYPES.STANDARD, widthMm: 48, lengthM: 66, thicknessMicron: 38, sleeveBrand: 'фрегат', quantityPerBox: '36' },
.replaceAll('–', '-'); { 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) { function buildName(product) {
return decodeHtmlEntities(value.replaceAll(/<[^>]*>/g, ' ').replaceAll(/\s+/g, ' ')).trim(); return [
sentenceCase(product.productType),
`${product.widthMm}x${product.lengthM} м`,
`${product.thicknessMicron} мкм`,
`втулка ${sentenceCase(product.sleeveBrand)}`,
`короб ${product.quantityPerBox}`,
].join(', ');
} }
function skuFromHref(href, index) { function baseQuantity(quantityPerBox) {
const pathname = href.replace(/^https?:\/\/[^/]+/i, '').split('?')[0] || ''; const firstValue = quantityPerBox.split('/')[0];
const segments = pathname.split('/').filter(Boolean); const parsed = Number.parseInt(firstValue, 10);
const slug = segments[segments.length - 1] ?? ''; return Number.isFinite(parsed) && parsed > 0 ? parsed : 24;
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()];
} }
const company = await prisma.company.upsert({ const company = await prisma.company.upsert({
@@ -140,23 +102,42 @@ const warehouseReserve = await prisma.warehouse.upsert({
create: { code: 'SPB-01', name: 'Reserve warehouse' }, create: { code: 'SPB-01', name: 'Reserve warehouse' },
}); });
const products = await loadCatalogProducts(); const activeSkus = PRODUCT_ROWS.map((item) => item.sku);
console.log(`Loaded ${products.length} products from fregat.org catalog.`); 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({ const dbProduct = await prisma.product.upsert({
where: { sku: product.sku }, where: { sku: product.sku },
update: { update: {
name: product.name, name: buildName(product),
description: product.description, description: `Артикул: ${product.sku}`,
isCustomizable: product.isCustomizable, productType: product.productType,
widthMm: product.widthMm,
lengthM: product.lengthM,
thicknessMicron: product.thicknessMicron,
sleeveBrand: product.sleeveBrand,
quantityPerBox: product.quantityPerBox,
isCustomizable: false,
isActive: true, isActive: true,
}, },
create: { create: {
sku: product.sku, sku: product.sku,
name: product.name, name: buildName(product),
description: product.description, description: `Артикул: ${product.sku}`,
isCustomizable: product.isCustomizable, productType: product.productType,
widthMm: product.widthMm,
lengthM: product.lengthM,
thicknessMicron: product.thicknessMicron,
sleeveBrand: product.sleeveBrand,
quantityPerBox: product.quantityPerBox,
isCustomizable: false,
isActive: true, isActive: true,
}, },
}); });
@@ -168,11 +149,11 @@ for (const product of products) {
warehouseId: warehouseMain.id, warehouseId: warehouseMain.id,
}, },
}, },
update: { availableQty: product.qtyMain }, update: { availableQty: qtyMain },
create: { create: {
productId: dbProduct.id, productId: dbProduct.id,
warehouseId: warehouseMain.id, warehouseId: warehouseMain.id,
availableQty: product.qtyMain, availableQty: qtyMain,
}, },
}); });
@@ -183,14 +164,14 @@ for (const product of products) {
warehouseId: warehouseReserve.id, warehouseId: warehouseReserve.id,
}, },
}, },
update: { availableQty: product.qtyReserve }, update: { availableQty: qtyReserve },
create: { create: {
productId: dbProduct.id, productId: dbProduct.id,
warehouseId: warehouseReserve.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(); await prisma.$disconnect();