generated from wiz4apps/templates.OnlineShop
306 lines
18 KiB
TypeScript
306 lines
18 KiB
TypeScript
import { readFileSync, writeFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { CATEGORIES, readCatalog } from '../src/db/catalog';
|
||
import { imageUrl, readImages } from '../src/db/images';
|
||
import { DEMO_PASSWORD_HASHES } from '../src/db/seed-accounts';
|
||
import { DELIVERY, STORE, STORE_CURRENCY } from '../src/store/constants';
|
||
|
||
/**
|
||
* Emit a single idempotent SQL script (db/seed/0001_seed.sql) that mirrors
|
||
* src/db/seed.ts. It is meant for the W4C Data Tables module (`/db-forms`,
|
||
* DataTables Expert `run_sql`), which can apply it in one statement to the
|
||
* platform `tenant_1` database without a Node process.
|
||
*
|
||
* The TypeScript seed remains the canonical source; run `pnpm db:sql` to refresh.
|
||
*/
|
||
|
||
const OUTPUT = fileURLToPath(new URL('../db/seed/0001_seed.sql', import.meta.url));
|
||
const GUARDSAFE = fileURLToPath(new URL('../db/seed/0001_seed.guardsafe.sql', import.meta.url));
|
||
|
||
/** Quote a value as a SQL literal. */
|
||
function q(value: string | null | undefined): string {
|
||
if (value === null || value === undefined) return 'NULL';
|
||
return `'${value.replace(/'/g, "''")}'`;
|
||
}
|
||
|
||
function qi(value: number | null): string {
|
||
return value === null ? 'NULL' : String(value);
|
||
}
|
||
|
||
function occasionFor(categories: string[]): string {
|
||
if (categories.includes('svadebnye-bukety-nevesty')) return 'Невесте';
|
||
if (categories.includes('monobuket')) return 'Любимой';
|
||
if (
|
||
categories.includes('kompozitsii') ||
|
||
categories.includes('tsvety-v-korzine') ||
|
||
categories.includes('tsvety-v-yashchike')
|
||
) {
|
||
return 'Коллеге, Партнёру';
|
||
}
|
||
return 'Любимой, Маме, Другу';
|
||
}
|
||
|
||
const products = readCatalog();
|
||
const images = readImages();
|
||
// Guard-safe constraints: no `ON CONFLICT`, no dotted aliases — plain VALUES rows.
|
||
const imageValues = images
|
||
.map(
|
||
(image) =>
|
||
` ((SELECT id FROM product WHERE slug = ${q(image.slug)}), ${q(imageUrl(image.slug, image.position))}, ${q(image.alt)}, ${image.position})`,
|
||
)
|
||
.join(',\n');
|
||
const imageSection = [
|
||
'INSERT INTO product_image (product_id, url, alt, position) VALUES',
|
||
imageValues,
|
||
';',
|
||
];
|
||
const lines: string[] = [];
|
||
|
||
lines.push('-- ⚠️ GENERATED FILE — do not edit by hand.');
|
||
lines.push('-- Source: src/db/seed.ts + db/seed/catalog.tsv. Regenerate with `pnpm db:sql`.');
|
||
lines.push('-- Idempotent: safe to run repeatedly against the tenant_1 schema.');
|
||
lines.push('-- Currency: BYN; money is integer minor units (BYN * 100) — no floats (R2/R4).');
|
||
lines.push('BEGIN;');
|
||
lines.push('');
|
||
|
||
// 1. cleanup of stray demo / placeholder rows
|
||
lines.push('-- 1. clean up stray demo / placeholder rows');
|
||
lines.push(
|
||
`DELETE FROM product WHERE slug = 'form-save-test' OR slug LIKE 'demo-%';`,
|
||
);
|
||
lines.push(
|
||
`DELETE FROM category WHERE slug IN ('placeholder-category-11','placeholder-category','hidden-1','hidden-2','hidden-3');`,
|
||
);
|
||
lines.push('');
|
||
|
||
// 2. categories
|
||
lines.push('-- 2. categories: 11 visible + 3 hidden = 14 (R13)');
|
||
lines.push('INSERT INTO category (slug, name, description, position, visible) VALUES');
|
||
lines.push(
|
||
CATEGORIES.map(
|
||
(c) => ` (${q(c.slug)}, ${q(c.name)}, ${q(c.description)}, ${c.position}, ${c.visible})`,
|
||
).join(',\n'),
|
||
);
|
||
lines.push('ON CONFLICT (slug) DO UPDATE SET');
|
||
lines.push(' name = EXCLUDED.name, description = EXCLUDED.description,');
|
||
lines.push(' position = EXCLUDED.position, visible = EXCLUDED.visible;');
|
||
lines.push('');
|
||
|
||
// 3. catalog via a temp table, then a set-based upsert
|
||
lines.push('-- 3. products: 93 rows from docs/CATALOG.md §3 (R14)');
|
||
lines.push('CREATE TEMP TABLE _seed_catalog (');
|
||
lines.push(' slug text PRIMARY KEY, name text, description text, price_minor integer,');
|
||
lines.push(' old_price_minor integer, currency text, badge text, stock integer, status text,');
|
||
lines.push(' composition text, stems integer, height_cm integer, care text, delivery_time text,');
|
||
lines.push(' category_slugs text[], occasion text');
|
||
lines.push(') ON COMMIT DROP;');
|
||
lines.push('INSERT INTO _seed_catalog VALUES');
|
||
lines.push(
|
||
products
|
||
.map((p) => {
|
||
const slugs = [...p.categories];
|
||
if (/роз/i.test(p.name) && !slugs.includes('bukety-iz-roz')) slugs.push('bukety-iz-roz');
|
||
const array = `ARRAY[${slugs.map((s) => q(s)).join(', ')}]::text[]`;
|
||
return ` (${q(p.slug)}, ${q(p.name)}, ${q(p.description)}, ${p.priceMinor}, ${qi(p.oldPriceMinor)}, ${q(p.currency)}, ${q(p.badge)}, ${p.stock}, ${q(p.status)}, ${q(p.composition)}, ${qi(p.stems)}, ${p.heightCm}, ${q(p.care)}, ${q(p.deliveryTime)}, ${array}, ${q(occasionFor(p.categories))})`;
|
||
})
|
||
.join(',\n'),
|
||
);
|
||
lines.push(';');
|
||
lines.push('');
|
||
lines.push('INSERT INTO product');
|
||
lines.push(' (slug, name, description, price_minor, old_price_minor, currency, badge, stock,');
|
||
lines.push(' status, composition, stems, height_cm, care, delivery_time)');
|
||
lines.push('SELECT slug, name, description, price_minor, old_price_minor, currency, badge, stock,');
|
||
lines.push(' status, composition, stems, height_cm, care, delivery_time FROM _seed_catalog');
|
||
lines.push('ON CONFLICT (slug) DO UPDATE SET');
|
||
lines.push(' name = EXCLUDED.name, description = EXCLUDED.description,');
|
||
lines.push(' price_minor = EXCLUDED.price_minor, old_price_minor = EXCLUDED.old_price_minor,');
|
||
lines.push(' currency = EXCLUDED.currency, badge = EXCLUDED.badge, stock = EXCLUDED.stock,');
|
||
lines.push(' status = EXCLUDED.status, composition = EXCLUDED.composition, stems = EXCLUDED.stems,');
|
||
lines.push(' height_cm = EXCLUDED.height_cm, care = EXCLUDED.care, delivery_time = EXCLUDED.delivery_time;');
|
||
lines.push('');
|
||
|
||
// 4. cross-listing
|
||
lines.push('-- 4. cross-listing (ProductCategory)');
|
||
lines.push('DELETE FROM product_category pc USING _seed_catalog s, product p');
|
||
lines.push(' WHERE pc.product_id = p.id AND p.slug = s.slug;');
|
||
lines.push('INSERT INTO product_category (product_id, category_id, position)');
|
||
lines.push('SELECT p.id, c.id, ord::int');
|
||
lines.push('FROM _seed_catalog s');
|
||
lines.push('JOIN product p ON p.slug = s.slug');
|
||
lines.push('CROSS JOIN LATERAL unnest(s.category_slugs) WITH ORDINALITY AS u(slug, ord)');
|
||
lines.push('JOIN category c ON c.slug = u.slug');
|
||
lines.push('ON CONFLICT (product_id, category_id) DO UPDATE SET position = EXCLUDED.position;');
|
||
lines.push('');
|
||
|
||
// 5. images
|
||
lines.push('-- 5. images: 1–4 originals per product from db/seed/images.tsv (docs/PLAN.md §11)');
|
||
lines.push('DELETE FROM product_image pi USING _seed_catalog s, product p');
|
||
lines.push(' WHERE pi.product_id = p.id AND p.slug = s.slug;');
|
||
lines.push(...imageSection);
|
||
lines.push('');
|
||
|
||
// 6. features
|
||
lines.push('-- 6. features (PDP «Кому дарим цветы»)');
|
||
lines.push('INSERT INTO product_feature (product_id, key, value)');
|
||
lines.push("SELECT p.id, 'Кому дарим цветы', s.occasion");
|
||
lines.push('FROM _seed_catalog s JOIN product p ON p.slug = s.slug');
|
||
lines.push('ON CONFLICT (product_id, key) DO UPDATE SET value = EXCLUDED.value;');
|
||
lines.push('');
|
||
|
||
// 7. variants
|
||
lines.push('-- 7. variants (source PDP «Букет из красных роз с эвкалиптом»: 15/19/25 роз)');
|
||
lines.push('DELETE FROM product_variant pv USING _seed_catalog s, product p');
|
||
lines.push(' WHERE pv.product_id = p.id AND p.slug = s.slug;');
|
||
lines.push('INSERT INTO product_variant (product_id, label, price_minor, stock)');
|
||
lines.push('SELECT p.id, v.label, v.price_minor, v.stock');
|
||
lines.push("FROM (VALUES ('buket-iz-krasnykh-roz-s-evkaliptom','15 роз',19800,10),");
|
||
lines.push(" ('buket-iz-krasnykh-roz-s-evkaliptom','19 роз',24000,6),");
|
||
lines.push(" ('buket-iz-krasnykh-roz-s-evkaliptom','25 роз',32500,4)) AS v(slug,label,price_minor,stock)");
|
||
lines.push('JOIN product p ON p.slug = v.slug;');
|
||
lines.push('');
|
||
|
||
// 8. customers
|
||
lines.push('-- 8. seeded accounts (demo credentials, documented in README)');
|
||
const adminHash = DEMO_PASSWORD_HASHES.admin;
|
||
const customerHash = DEMO_PASSWORD_HASHES.customer;
|
||
lines.push('INSERT INTO customer (email, name, phone, password_hash, role) VALUES');
|
||
lines.push(` (${q('admin@la-rose.by')}, ${q('Администратор')}, ${q('+375290000001')}, ${q(adminHash)}, 'admin'),`);
|
||
lines.push(` (${q('customer@example.com')}, ${q('Иван Петров')}, ${q('+375290000002')}, ${q(customerHash)}, 'customer')`);
|
||
lines.push('ON CONFLICT (email) DO UPDATE SET');
|
||
lines.push(' name = EXCLUDED.name, phone = EXCLUDED.phone, role = EXCLUDED.role,');
|
||
lines.push(' password_hash = EXCLUDED.password_hash;');
|
||
lines.push('');
|
||
|
||
// 9. address
|
||
lines.push('-- 9. address for the demo customer');
|
||
lines.push("DELETE FROM address WHERE customer_id = (SELECT id FROM customer WHERE email = 'customer@example.com');");
|
||
lines.push('INSERT INTO address (customer_id, label, city, street, building, apartment, postal_code, is_default)');
|
||
lines.push("VALUES ((SELECT id FROM customer WHERE email = 'customer@example.com'),");
|
||
lines.push(" 'Дом', 'Минск', 'ул. Независимости', '10', '25', '220030', true);");
|
||
lines.push('');
|
||
|
||
// 10. promo codes
|
||
lines.push('-- 10. promo codes');
|
||
lines.push('INSERT INTO promo_code (code, discount_percent, min_total_minor, active) VALUES');
|
||
lines.push(" ('ROSE10', 10, 0, true)");
|
||
lines.push('ON CONFLICT (code) DO UPDATE SET discount_percent = EXCLUDED.discount_percent,');
|
||
lines.push(' min_total_minor = EXCLUDED.min_total_minor, active = EXCLUDED.active;');
|
||
lines.push('INSERT INTO promo_code (code, discount_percent, min_total_minor, active, expires_at) VALUES');
|
||
lines.push(" ('LOVE20', 20, 10000, true, '2026-12-31T20:59:59Z')");
|
||
lines.push('ON CONFLICT (code) DO UPDATE SET discount_percent = EXCLUDED.discount_percent,');
|
||
lines.push(' min_total_minor = EXCLUDED.min_total_minor, active = EXCLUDED.active, expires_at = EXCLUDED.expires_at;');
|
||
lines.push('');
|
||
|
||
// 11. content pages
|
||
const pages: Array<[string, string, string]> = [
|
||
['o-kompanii', 'О компании', 'La Rose — минская доставка цветов и подарков. Работаем ежедневно, собираем букеты вручную.'],
|
||
['dostavka', 'Доставка', 'Курьер по Минску и самовывоз из салона. Бесплатно при заказе от 100 BYN, иначе 15 BYN. За МКАД — 15 BYN + 0.50 BYN/км.'],
|
||
['oplata', 'Оплата', 'Оплата картой онлайн (Visa, Mastercard, МИР, Белкарт, ЕРИП) или наличными курьеру при получении.'],
|
||
['kak-zakazat', 'Как заказать', 'Соберите букет в каталоге, добавьте в корзину, оформите заказ и укажите получателя.'],
|
||
['vozvrat', 'Возврат', 'Свежие цветы возврату не подлежат; по вопросам качества свяжитесь с нами в день доставки.'],
|
||
['kontakty', 'Контакты', `Телефон: ${STORE.phone1}. Адрес самовывоза: ${STORE.pickupAddress}.`],
|
||
['b2b', 'Корпоративным клиентам', 'Корпоративное оформление, постоянные букеты в офис и подарки партнёрам. Заключаем договор и выдаём документы.'],
|
||
['vakansii', 'Вакансии', 'Флорист и курьер. Присылайте резюме на info@la-rose.by.'],
|
||
['politika', 'Политика конфиденциальности', 'Мы обрабатываем персональные данные только для выполнения заказа и не передаём их третьим лицам, кроме платёжного провайдера.'],
|
||
['faq', 'Вопросы и ответы', 'Как быстро доставите? Обычно 2–4 часа по Минску. Можно ли добавить открытку? Да, укажите текст в оформлении заказа.'],
|
||
];
|
||
lines.push('-- 11. content pages (R11)');
|
||
lines.push('INSERT INTO content_page (slug, title, body, published) VALUES');
|
||
lines.push(pages.map((p) => ` (${q(p[0])}, ${q(p[1])}, ${q(p[2])}, true)`).join(',\n'));
|
||
lines.push('ON CONFLICT (slug) DO UPDATE SET title = EXCLUDED.title, body = EXCLUDED.body, published = true;');
|
||
lines.push('');
|
||
|
||
// 12. delivery options
|
||
lines.push('-- 12. delivery options (R7)');
|
||
lines.push('INSERT INTO delivery_option (id, name, price_minor, free_over_minor) VALUES');
|
||
lines.push(` ('todoor', ${q('Доставка цветов по Минску')}, ${DELIVERY.courierFeeMinor}, ${DELIVERY.freeOverMinor}),`);
|
||
lines.push(` ('pickup', ${q('Самовывоз из салона')}, 0, NULL)`);
|
||
lines.push('ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name,');
|
||
lines.push(' price_minor = EXCLUDED.price_minor, free_over_minor = EXCLUDED.free_over_minor;');
|
||
lines.push('');
|
||
|
||
// 13. orders + items + payments
|
||
lines.push('-- 13. two orders (>= 1 admin, >= 1 customer, >= 2 orders)');
|
||
lines.push("DELETE FROM order_item WHERE order_id = (SELECT id FROM orders WHERE number = 'LR-1001');");
|
||
lines.push("DELETE FROM payment WHERE order_id = (SELECT id FROM orders WHERE number = 'LR-1001');");
|
||
lines.push("DELETE FROM order_item WHERE order_id = (SELECT id FROM orders WHERE number = 'LR-1002');");
|
||
lines.push("DELETE FROM payment WHERE order_id = (SELECT id FROM orders WHERE number = 'LR-1002');");
|
||
lines.push('INSERT INTO orders');
|
||
lines.push(' (number, status, customer_id, address_id, subtotal_minor, shipping_minor, discount_minor,');
|
||
lines.push(' total_minor, currency, gift_note, delivery_date, delivery_time, promo_code_id) VALUES');
|
||
lines.push(" ('LR-1001', 'paid', (SELECT id FROM customer WHERE email = 'customer@example.com'),");
|
||
lines.push(" (SELECT id FROM address ORDER BY id DESC LIMIT 1), 19800, 0, 1980, 17820, 'BYN',");
|
||
lines.push(" 'С днём рождения!', '2026-10-05', '18:00', (SELECT id FROM promo_code WHERE code = 'ROSE10')),");
|
||
lines.push(" ('LR-1002', 'pending', (SELECT id FROM customer WHERE email = 'customer@example.com'),");
|
||
lines.push(" (SELECT id FROM address ORDER BY id DESC LIMIT 1), 7100, 1500, 0, 8600, 'BYN',");
|
||
lines.push(" NULL, '2026-10-06', '12:00', NULL)");
|
||
lines.push('ON CONFLICT (number) DO UPDATE SET');
|
||
lines.push(' status = EXCLUDED.status, customer_id = EXCLUDED.customer_id, address_id = EXCLUDED.address_id,');
|
||
lines.push(' subtotal_minor = EXCLUDED.subtotal_minor, shipping_minor = EXCLUDED.shipping_minor,');
|
||
lines.push(' discount_minor = EXCLUDED.discount_minor, total_minor = EXCLUDED.total_minor,');
|
||
lines.push(' currency = EXCLUDED.currency, gift_note = EXCLUDED.gift_note,');
|
||
lines.push(' delivery_date = EXCLUDED.delivery_date, delivery_time = EXCLUDED.delivery_time,');
|
||
lines.push(' promo_code_id = EXCLUDED.promo_code_id;');
|
||
lines.push('INSERT INTO order_item (order_id, product_id, name, unit_price_minor, quantity) VALUES');
|
||
lines.push(" ((SELECT id FROM orders WHERE number = 'LR-1001'),");
|
||
lines.push(" (SELECT id FROM product WHERE slug = 'buket-iz-krasnykh-roz-s-evkaliptom'),");
|
||
lines.push(" 'Букет из красных роз с эвкалиптом', 19800, 1),");
|
||
lines.push(" ((SELECT id FROM orders WHERE number = 'LR-1002'),");
|
||
lines.push(" (SELECT id FROM product WHERE slug = 'buket-nostalgiya'), '«Летний»', 7100, 1);");
|
||
lines.push('INSERT INTO payment (order_id, provider, provider_ref, status, amount_minor) VALUES');
|
||
lines.push(" ((SELECT id FROM orders WHERE number = 'LR-1001'), 'bepaid', 'PAY-DEMO-0001', 'succeeded', 17820),");
|
||
lines.push(" ((SELECT id FROM orders WHERE number = 'LR-1002'), 'cash', NULL, 'pending', 8600);");
|
||
lines.push('');
|
||
|
||
// 14. cart
|
||
lines.push('-- 14. demo cart');
|
||
lines.push('INSERT INTO cart (session_token, customer_id) VALUES');
|
||
lines.push(" ('demo-session-token-0001', (SELECT id FROM customer WHERE email = 'customer@example.com'))");
|
||
lines.push('ON CONFLICT (session_token) DO UPDATE SET customer_id = EXCLUDED.customer_id, updated_at = now();');
|
||
lines.push("DELETE FROM cart_item WHERE cart_id = (SELECT id FROM cart WHERE session_token = 'demo-session-token-0001');");
|
||
lines.push('INSERT INTO cart_item (cart_id, product_id, quantity, unit_price_minor) VALUES');
|
||
lines.push(" ((SELECT id FROM cart WHERE session_token = 'demo-session-token-0001'),");
|
||
lines.push(" (SELECT id FROM product WHERE slug = 'buket-nostalgiya'), 1, 7100),");
|
||
lines.push(" ((SELECT id FROM cart WHERE session_token = 'demo-session-token-0001'),");
|
||
lines.push(" (SELECT id FROM product WHERE slug = 'sovershenstvo'), 2, 12500);");
|
||
lines.push('');
|
||
|
||
lines.push('COMMIT;');
|
||
lines.push('');
|
||
|
||
writeFileSync(OUTPUT, lines.join('\n'), 'utf8');
|
||
|
||
/**
|
||
* The guard-safe variant (used by the W4C `/db-forms` module) is hand-maintained,
|
||
* but its image block must stay identical to the generated seed — /db-forms
|
||
* rejects `ON CONFLICT` and dotted aliases. Replace just that block in place.
|
||
*/
|
||
function syncGuardsafeImages(): void {
|
||
let text: string;
|
||
try {
|
||
text = readFileSync(GUARDSAFE, 'utf8');
|
||
} catch {
|
||
return;
|
||
}
|
||
const block = text.split('\n');
|
||
const start = block.findIndex((line) => line.startsWith('-- 10. images'));
|
||
const end = block.findIndex((line) => line.startsWith('-- 11.'));
|
||
if (start < 0 || end <= start) return;
|
||
block.splice(
|
||
start,
|
||
end - start,
|
||
'-- 10. images: 1–4 originals per product from db/seed/images.tsv (docs/PLAN.md §11)',
|
||
...imageSection,
|
||
'',
|
||
);
|
||
writeFileSync(GUARDSAFE, block.join('\n'), 'utf8');
|
||
console.log(` guard-safe images synced: ${GUARDSAFE} (${images.length} rows)`);
|
||
}
|
||
|
||
syncGuardsafeImages();
|
||
|
||
console.log(`generate-seed-sql: wrote ${OUTPUT} (${products.length} products, ${CATEGORIES.length} categories)`);
|
||
console.log(` images: ${images.length} product_image rows (db/seed/images.tsv)`);
|
||
console.log(` currency=${STORE_CURRENCY} (integer minor units, no floats)`);
|