metallkart-erp/scripts/seed-staging.ts
louis 30488ff007 feat(work-log): native work accounting system (SPEC-TRUD-3-DECOMPTE)
Worker model + WorkLog with 3 payment modes (PIECE/HOURLY/AREA),
anti-sur-décompte ceiling alerts (non-bloquant), production
verification workflow (PENDING→VERIFIED→actualLabor recompute),
9 REST endpoints, 18 Vitest tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 17:06:26 +04:00

633 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { PrismaClient, Prisma } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { seedHelpArticles } from '../src/modules/help/help.seed.js';
import { seedNotificationCategoryConfigs } from '../src/modules/notifications/notification-category-config.seed.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ═══════════════════════════════════════════════════════════════
// SEED-STAGING-HARDENING — Refonte robuste du script seed-staging
//
// Principes :
// 1. Les tables RÉFÉRENTIEL ne sont JAMAIS tronquées
// 2. Les données métier sont externalisées dans scripts/data/*.json
// 3. Users/Clients/Suppliers seedés par upsert (idempotent)
// 4. Tables OPÉRATIONNEL tronquées avant re-seed
// 5. Invariants vérifiés en post-seed
// 6. Dry-run mode (--dry-run) pour valider sans écrire
// 7. Production guard renforcé
// ═══════════════════════════════════════════════════════════════
const PASSWORD_STAGING = 'MK_Staging_2026!';
const BCRYPT_ROUNDS = 10;
const DRY_RUN = process.argv.includes('--dry-run');
const DATA_DIR = join(__dirname, 'data');
// ─── JSON loaders ─────────────────────────────────────────────
function loadJson<T>(filename: string): T {
const raw = readFileSync(join(DATA_DIR, filename), 'utf-8');
return JSON.parse(raw) as T;
}
interface StagingUser { email: string; name: string; role: string }
interface StagingClient { companyName: string; inn: string; abbreviation: string }
interface StagingSupplier { companyName: string; inn: string; contactEmail: string; status: string; isInternal?: boolean }
interface ContractTerm { trigger: string; percent: number; label: string; offsetDays: number; sortOrder: number }
interface ClientContractDef { ref: string; clientAbbrev: string; contractNumber: string; contractDate: string; startDate: string; status: string; terms: ContractTerm[] }
interface SupplierContractDef { supplierInn: string; contractNumber: string; contractDate: string; startDate: string; status: string; signedAt?: string; terms: ContractTerm[] }
interface ContractData { clientContracts: ClientContractDef[]; supplierContracts: SupplierContractDef[] }
interface SmetaLineDef { lineType: string; lineCategory: string; description: string; unit: string; quantity: number; unitPrice: number; position: number }
interface SmetaDef { orderCode: string; sellingPrice: number; lines: SmetaLineDef[] }
interface OrderDef { orderCode: string; productName: string; quantity: number; status: string; clientAbbrev: string; managerEmail: string; contractRef?: string; executionUnit?: string; launchDate?: string; deliveryDate?: string }
interface PrDef { orderCode: string; materialCategory: string; description: string; quantity: number; unit: string; estimatedPrice: number; status: string; priority?: string; sourceType?: string }
// ─── Safety guards ────────────────────────────────────────────
function assertSafeDatabase(url: string) {
const masked = url.replace(/:[^@]+@/, ':***@');
if (url.includes('erp_local')) {
throw new Error(
`[SAFETY] REFUSED — erp_local detected.\n Got: ${masked}\n This seed targets erp_staging or erp_test only.`
);
}
if (!url.includes('erp_staging') && !url.includes('erp_test')) {
throw new Error(
`[SAFETY] REFUSED — DATABASE_URL must target erp_staging or erp_test.\n Got: ${masked}`
);
}
console.log(`[SAFETY] OK — DB target: ${masked}`);
}
// ─── Truncate OPÉRATIONNEL only ────────────────────────────────
// RÉFÉRENTIEL tables NEVER truncated:
// expense_codes, supplier_tags, public_holidays, help_articles,
// payment_templates, payment_template_terms, steel_references,
// notification_category_configs, smeta_budget_mappings,
// reminder_configs, engagement_auto_rules
async function truncateOperational(prisma: PrismaClient) {
await prisma.$executeRawUnsafe(`
TRUNCATE TABLE
audit_logs,
cashflow_entries,
client_installments,
forecast_installments,
client_payments_received,
planned_delivery_lots,
delivery_lots,
po_installments,
po_payment_schedules,
payment_requests,
purchase_order_lines,
purchase_orders,
purchase_lot_lines,
purchase_lots,
tender_offer_lines,
tender_line_awards,
tender_offers,
tender_dispatches,
tenders,
purchase_requirements,
purchase_line_receptions,
supplier_delivery_notes,
supplier_channel_links,
channel_pairing_codes,
max_outbound_messages,
max_inbound_messages,
supplier_notification_logs,
work_logs,
order_labor_operations,
workers,
smeta_labor_nodes,
smeta_lines,
smeta_revisions,
additional_costs,
smetas,
estimate_comments,
contract_payment_terms,
client_contracts,
client_invoices,
supplier_contract_payment_terms,
supplier_contracts,
documents,
document_files,
file_tracking_configs,
forecast_rebalance_logs,
order_status_history,
order_revisions,
order_category_notes,
order_photos,
financial_trackings,
budget_validations,
monthly_budgets,
recurring_payments,
notifications,
po_invoices,
bank_statements,
onec_sync_log,
onec_bank_accounts,
onec_cashflow_categories,
category_tag_mappings,
orders,
user_notification_preferences,
push_subscriptions,
notification_deliveries,
reminders,
decision_logs,
decision_entity_links,
task_entity_links,
task_dependencies,
decisions,
tasks,
user_memories,
agenda_calendar_configs
RESTART IDENTITY CASCADE;
`);
console.log('[TRUNCATE] OK — operational tables only (référentiels preserved)');
}
// ─── Seed RÉFÉRENTIEL (idempotent) ─────────────────────────────
async function seedReferentials(prisma: PrismaClient) {
console.log('\n[RÉFÉRENTIEL] Seeding idempotent referentials...');
// 1. Expense codes + supplier tags (from seed-expense-codes.ts logic)
const { seedExpenseCodes } = await import('./seed-referentials-helpers.js');
await seedExpenseCodes(prisma);
// 2. Notification category configs
await seedNotificationCategoryConfigs(prisma);
const notifCount = await prisma.notificationCategoryConfig.count();
console.log(` notification_category_configs: ${notifCount}`);
// 3. Public holidays
const HOLIDAYS_2026 = [
{ date: '2026-01-01', name: 'Новый год' },
{ date: '2026-01-02', name: 'Новогодние каникулы' },
{ date: '2026-01-03', name: 'Новогодние каникулы' },
{ date: '2026-01-04', name: 'Новогодние каникулы' },
{ date: '2026-01-05', name: 'Новогодние каникулы' },
{ date: '2026-01-06', name: 'Новогодние каникулы' },
{ date: '2026-01-07', name: 'Рождество Христово' },
{ date: '2026-01-08', name: 'Новогодние каникулы' },
{ date: '2026-02-23', name: 'День защитника Отечества' },
{ date: '2026-03-08', name: 'Международный женский день' },
{ date: '2026-05-01', name: 'Праздник Весны и Труда' },
{ date: '2026-05-02', name: 'Праздник Весны и Труда' },
{ date: '2026-05-03', name: 'Праздник Весны и Труда' },
{ date: '2026-05-04', name: 'Праздник Весны и Труда' },
{ date: '2026-05-05', name: 'Праздник Весны и Труда' },
{ date: '2026-05-09', name: 'День Победы' },
{ date: '2026-06-12', name: 'День России' },
{ date: '2026-11-04', name: 'День народного единства' },
];
for (const h of HOLIDAYS_2026) {
await prisma.publicHoliday.upsert({
where: { date: new Date(h.date) },
update: {},
create: { date: new Date(h.date), name: h.name, holidayType: 'FIXED' as any },
});
}
const holidayCount = await prisma.publicHoliday.count();
console.log(` public_holidays: ${holidayCount}`);
// 4. Help articles
await seedHelpArticles(prisma);
const helpCount = await prisma.helpArticle.count();
console.log(` help_articles: ${helpCount}`);
// 5. Payment templates (findFirst + skip pattern)
const TEMPLATES = [
{ name: '100% предоплата', description: 'Полная предоплата при размещении заказа',
terms: [{ percent: 100, trigger: 'ORDER_DATE', offsetDays: 0, label: 'Полная предоплата', sortOrder: 1 }] },
{ name: '50% предоплата + 50% по факту отгрузки', description: 'Аванс 50% + остаток при отгрузке',
terms: [
{ percent: 50, trigger: 'ORDER_DATE', offsetDays: 0, label: 'Предоплата', sortOrder: 1 },
{ percent: 50, trigger: 'SHIPMENT_DATE', offsetDays: 0, label: 'По факту отгрузки', sortOrder: 2 },
] },
{ name: '30/70 post-livraison 15 дней', description: 'Аванс 30% + 70% через 15 дней после отгрузки',
terms: [
{ percent: 30, trigger: 'ORDER_DATE', offsetDays: 0, label: 'Предоплата', sortOrder: 1 },
{ percent: 70, trigger: 'SHIPMENT_DATE', offsetDays: 15, label: 'По факту отгрузки J+15', sortOrder: 2 },
] },
{ name: 'Отсрочка 30 дней post-livraison', description: '100% через 30 дней после отгрузки',
terms: [{ percent: 100, trigger: 'SHIPMENT_DATE', offsetDays: 30, label: 'Отсрочка 30 дней', sortOrder: 1 }] },
{ name: 'Отсрочка 60 дней post-livraison', description: '100% через 60 дней после отгрузки',
terms: [{ percent: 100, trigger: 'SHIPMENT_DATE', offsetDays: 60, label: 'Отсрочка 60 дней', sortOrder: 1 }] },
];
for (const tpl of TEMPLATES) {
const existing = await prisma.paymentTemplate.findFirst({ where: { name: tpl.name } });
if (!existing) {
await prisma.paymentTemplate.create({
data: {
name: tpl.name,
description: tpl.description,
terms: { create: tpl.terms.map(t => ({ ...t, percent: new Prisma.Decimal(t.percent), trigger: t.trigger as any })) },
},
});
}
}
const tplCount = await prisma.paymentTemplate.count();
console.log(` payment_templates: ${tplCount}`);
// 6. Engagement auto-rules
const { seedEngagementAutoRules } = await import('../src/modules/engagement/engagement-auto-rules.seed.js');
await seedEngagementAutoRules(prisma);
const autoRuleCount = await prisma.engagementAutoRule.count();
console.log(` engagement_auto_rules: ${autoRuleCount}`);
console.log('[RÉFÉRENTIEL] OK');
}
// ═══════════════════════════════════════════════════════════════
// MAIN SEED
// ═══════════════════════════════════════════════════════════════
async function seedStaging(prisma: PrismaClient) {
// ─── Step 0: Referentials (idempotent, BEFORE truncate) ─────
await seedReferentials(prisma);
// ─── Step 1: Truncate OPÉRATIONNEL ──────────────────────────
await truncateOperational(prisma);
// ─── Step 2: Users (upsert by email) ────────────────────────
const usersData = loadJson<StagingUser[]>('staging-users.json');
const passwordHash = bcrypt.hashSync(PASSWORD_STAGING, BCRYPT_ROUNDS);
const userMap: Record<string, { id: string; email: string }> = {};
for (const u of usersData) {
const record = await prisma.user.upsert({
where: { email: u.email },
update: { name: u.name, role: u.role as any, active: true },
create: { email: u.email, password: passwordHash, name: u.name, role: u.role as any, active: true },
});
userMap[u.email] = { id: record.id, email: record.email };
}
console.log(`[USERS] ${usersData.length} users upserted (password NOT overwritten for existing)`);
// ─── Step 3: Clients (upsert by inn) ────────────────────────
const clientsData = loadJson<StagingClient[]>('staging-clients.json');
const clientMap: Record<string, number> = {};
for (const c of clientsData) {
const record = await prisma.client.upsert({
where: { inn: c.inn },
update: { companyName: c.companyName, abbreviation: c.abbreviation, isActive: true },
create: { companyName: c.companyName, inn: c.inn, abbreviation: c.abbreviation, isActive: true },
});
clientMap[c.abbreviation] = record.id;
}
console.log(`[CLIENTS] ${clientsData.length} clients upserted`);
// ─── Step 4: Suppliers (upsert by inn) ──────────────────────
const suppliersData = loadJson<StagingSupplier[]>('staging-suppliers.json');
const supplierMap: Record<string, number> = {};
for (const s of suppliersData) {
const record = await prisma.supplier.upsert({
where: { inn: s.inn },
update: { companyName: s.companyName, contactEmail: s.contactEmail, status: s.status as any },
create: { companyName: s.companyName, inn: s.inn, contactEmail: s.contactEmail, status: s.status as any, isInternal: s.isInternal ?? false },
});
supplierMap[s.inn] = record.id;
}
console.log(`[SUPPLIERS] ${suppliersData.length} suppliers upserted`);
// ─── Step 5: Client contracts ───────────────────────────────
const contractsData = loadJson<ContractData>('staging-contracts.json');
const contractRefMap: Record<string, number> = {};
for (const cc of contractsData.clientContracts) {
const clientId = clientMap[cc.clientAbbrev];
if (!clientId) { console.warn(` SKIP contract ${cc.ref} — client ${cc.clientAbbrev} not found`); continue; }
const record = await prisma.clientContract.create({
data: {
clientId,
contractNumber: cc.contractNumber,
contractDate: new Date(cc.contractDate),
startDate: new Date(cc.startDate),
status: cc.status as any,
},
});
contractRefMap[cc.ref] = record.id;
await prisma.contractPaymentTerm.createMany({
data: cc.terms.map(t => ({
contractId: record.id,
trigger: t.trigger as any,
percent: new Prisma.Decimal(t.percent),
label: t.label,
offsetDays: t.offsetDays,
sortOrder: t.sortOrder,
})),
});
}
console.log(`[CLIENT_CONTRACTS] ${contractsData.clientContracts.length} contracts created`);
// ─── Step 6: Supplier contracts ─────────────────────────────
for (const sc of contractsData.supplierContracts) {
const supplierId = supplierMap[sc.supplierInn];
if (!supplierId) { console.warn(` SKIP supplier contract ${sc.contractNumber} — supplier ${sc.supplierInn} not found`); continue; }
const record = await prisma.supplierContract.create({
data: {
supplierId,
contractNumber: sc.contractNumber,
contractDate: new Date(sc.contractDate),
startDate: new Date(sc.startDate),
status: sc.status as any,
signedAt: sc.signedAt ? new Date(sc.signedAt) : null,
},
});
await prisma.supplierContractPaymentTerm.createMany({
data: sc.terms.map(t => ({
contractId: record.id,
trigger: t.trigger as any,
percent: new Prisma.Decimal(t.percent),
label: t.label,
offsetDays: t.offsetDays,
sortOrder: t.sortOrder,
})),
});
}
console.log(`[SUPPLIER_CONTRACTS] ${contractsData.supplierContracts.length} contracts created`);
// ─── Step 7: Orders ─────────────────────────────────────────
const ordersData = loadJson<OrderDef[]>('staging-orders.json');
const orderMap: Record<string, { id: number; code: string }> = {};
for (const def of ordersData) {
const clientId = clientMap[def.clientAbbrev];
const managerId = userMap[def.managerEmail]?.id;
if (!clientId || !managerId) { console.warn(` SKIP order ${def.orderCode} — missing ref`); continue; }
const o = await prisma.order.create({
data: {
orderCode: def.orderCode,
productName: def.productName,
quantity: def.quantity,
status: def.status as any,
clientId,
managerId,
contractId: def.contractRef ? contractRefMap[def.contractRef] : undefined,
executionUnit: (def.executionUnit ?? 'MAIN_PRODUCTION') as any,
launchDate: def.launchDate ? new Date(def.launchDate) : undefined,
deliveryDate: def.deliveryDate ? new Date(def.deliveryDate) : undefined,
},
});
orderMap[def.orderCode] = { id: o.id, code: o.orderCode };
}
console.log(`[ORDERS] ${ordersData.length} orders created`);
// Status history for non-DRAFT orders
const statusHistoryDefs = [
{ orderCode: 'MK-2026-004', fromStatus: 'VALIDATED', toStatus: 'IN_PRODUCTION', changedByEmail: 'nikita@metallcart.ru' },
{ orderCode: 'MK-2026-005', fromStatus: 'VALIDATED', toStatus: 'IN_PRODUCTION', changedByEmail: 'nikita@metallcart.ru' },
{ orderCode: 'MK-2026-006', fromStatus: 'VALIDATED', toStatus: 'IN_PRODUCTION', changedByEmail: 'nikita@metallcart.ru' },
{ orderCode: 'MK-2026-007', fromStatus: 'IN_PRODUCTION', toStatus: 'SHIPPED', changedByEmail: 'nikita@metallcart.ru' },
{ orderCode: 'MK-2026-008', fromStatus: 'IN_PRODUCTION', toStatus: 'SHIPPED', changedByEmail: 'nikita@metallcart.ru' },
{ orderCode: 'MK-2026-009', fromStatus: 'SHIPPED', toStatus: 'DELIVERED', changedByEmail: 'petrov@metallcart.ru' },
{ orderCode: 'MK-2026-010', fromStatus: 'SHIPPED', toStatus: 'DELIVERED', changedByEmail: 'petrov@metallcart.ru' },
];
for (const sh of statusHistoryDefs) {
const orderId = orderMap[sh.orderCode]?.id;
const changedById = userMap[sh.changedByEmail]?.id;
if (orderId && changedById) {
await prisma.orderStatusHistory.create({ data: { orderId, fromStatus: sh.fromStatus, toStatus: sh.toStatus, changedById } as any });
}
}
// ─── Step 8: Smetas ─────────────────────────────────────────
const smetasData = loadJson<SmetaDef[]>('staging-smetas.json');
for (const sd of smetasData) {
const orderId = orderMap[sd.orderCode]?.id;
if (!orderId) { console.warn(` SKIP smeta for ${sd.orderCode} — order not found`); continue; }
const totalMaterials = sd.lines.filter(l => l.lineType === 'MATERIAL').reduce((s, l) => s + l.quantity * l.unitPrice, 0);
const totalServices = sd.lines.filter(l => l.lineType === 'SERVICE').reduce((s, l) => s + l.quantity * l.unitPrice, 0);
const totalLabor = sd.lines.filter(l => l.lineType === 'LABOR').reduce((s, l) => s + l.quantity * l.unitPrice, 0);
const smeta = await prisma.smeta.create({
data: {
orderId,
status: 'LOCKED',
isLocked: true,
lockedAt: new Date('2026-04-01'),
sellingPrice: new Prisma.Decimal(sd.sellingPrice),
totalMaterials: new Prisma.Decimal(totalMaterials),
totalServices: new Prisma.Decimal(totalServices),
totalLabor: new Prisma.Decimal(totalLabor),
importSource: 'EXCEL',
importedFile: `smeta-${sd.orderCode}.xlsx`,
},
});
await prisma.smetaLine.createMany({
data: sd.lines.map(l => ({
smetaId: smeta.id,
lineType: l.lineType as any,
lineCategory: l.lineCategory,
description: l.description,
unit: l.unit,
quantity: new Prisma.Decimal(l.quantity),
unitPrice: new Prisma.Decimal(l.unitPrice),
totalPrice: new Prisma.Decimal(l.quantity * l.unitPrice),
position: l.position,
})),
});
}
console.log(`[SMETAS] ${smetasData.length} smetas created (all LOCKED)`);
// ─── Step 9: Purchase requirements ──────────────────────────
const prData = loadJson<PrDef[]>('staging-purchase-requirements.json');
for (const pr of prData) {
const orderId = orderMap[pr.orderCode]?.id;
if (!orderId) { console.warn(` SKIP PR for ${pr.orderCode} — order not found`); continue; }
await prisma.purchaseRequirement.create({
data: {
orderId,
materialCategory: pr.materialCategory as any,
description: pr.description,
quantity: new Prisma.Decimal(pr.quantity),
originalQuantity: new Prisma.Decimal(pr.quantity),
unit: pr.unit,
estimatedPrice: new Prisma.Decimal(pr.estimatedPrice),
budgetAmount: new Prisma.Decimal(pr.quantity * pr.estimatedPrice),
status: pr.status as any,
priority: (pr.priority ?? 'NORMAL') as any,
sourceType: pr.sourceType,
},
});
}
console.log(`[PR] ${prData.length} purchase requirements created`);
// ─── Step 10: Audit logs ────────────────────────────────────
const now = new Date();
const auditDefs: Array<{ daysAgo: number; userEmail: string; action: string; entityType: string; entityRef: string; details?: object }> = [
{ daysAgo: 14, userEmail: 'anna@metallcart.ru', action: 'ORDER_CREATE', entityType: 'ORDER', entityRef: 'MK-2026-001', details: { orderCode: 'MK-2026-001', productName: 'Ролл-контейнер РК-800' } },
{ daysAgo: 13, userEmail: 'anna@metallcart.ru', action: 'ORDER_CREATE', entityType: 'ORDER', entityRef: 'MK-2026-002', details: { orderCode: 'MK-2026-002', productName: 'Стеллаж палетный СП-3000' } },
{ daysAgo: 12, userEmail: 'anna@metallcart.ru', action: 'ORDER_CREATE', entityType: 'ORDER', entityRef: 'MK-2026-003', details: { orderCode: 'MK-2026-003', productName: 'Корзина торговая КТ-30' } },
{ daysAgo: 11, userEmail: 'anna@metallcart.ru', action: 'ORDER_CREATE', entityType: 'ORDER', entityRef: 'MK-2026-004', details: { orderCode: 'MK-2026-004', productName: 'Грузовая тележка ГТ-500' } },
{ daysAgo: 10, userEmail: 'vasilieva@metallcart.ru', action: 'SMETA_IMPORT', entityType: 'ORDER', entityRef: 'MK-2026-004', details: { file: 'smeta-ГТ-500.xlsx' } },
{ daysAgo: 10, userEmail: 'vasilieva@metallcart.ru', action: 'SMETA_IMPORT', entityType: 'ORDER', entityRef: 'MK-2026-005', details: { file: 'smeta-КС-1200.xlsx' } },
{ daysAgo: 9, userEmail: 'martyanov@metallcart.ru', action: 'SMETA_REVISION_APPROVE', entityType: 'ORDER', entityRef: 'MK-2026-004', details: { version: 1 } },
{ daysAgo: 9, userEmail: 'martyanov@metallcart.ru', action: 'SMETA_REVISION_APPROVE', entityType: 'ORDER', entityRef: 'MK-2026-005', details: { version: 1 } },
{ daysAgo: 8, userEmail: 'evgeniy@metallcart.ru', action: 'PR_SUBMIT', entityType: 'ORDER', entityRef: 'MK-2026-004', details: { count: 4 } },
{ daysAgo: 8, userEmail: 'evgeniy@metallcart.ru', action: 'PR_SUBMIT', entityType: 'ORDER', entityRef: 'MK-2026-005', details: { count: 5 } },
{ daysAgo: 7, userEmail: 'evgeniy@metallcart.ru', action: 'TENDER_CREATE', entityType: 'TENDER', entityRef: '1', details: { referenceCode: 'T-2026-001', description: 'Тендер на металл для ГТ-500' } },
{ daysAgo: 6, userEmail: 'evgeniy@metallcart.ru', action: 'TENDER_AWARD', entityType: 'TENDER', entityRef: '1', details: { supplier: 'Северсталь-Метиз' } },
{ daysAgo: 5, userEmail: 'evgeniy@metallcart.ru', action: 'PO_CONFIRM', entityType: 'PURCHASE_ORDER', entityRef: '1', details: { orderCode: 'PO-2026-001', total: 220000 } },
{ daysAgo: 4, userEmail: 'nikita@metallcart.ru', action: 'ORDER_STATUS_CHANGE', entityType: 'ORDER', entityRef: 'MK-2026-004', details: { from: 'VALIDATED', to: 'IN_PRODUCTION' } },
{ daysAgo: 3, userEmail: 'nikita@metallcart.ru', action: 'ORDER_STATUS_CHANGE', entityType: 'ORDER', entityRef: 'MK-2026-005', details: { from: 'VALIDATED', to: 'IN_PRODUCTION' } },
{ daysAgo: 3, userEmail: 'vasilieva@metallcart.ru', action: 'SMETA_IMPORT', entityType: 'ORDER', entityRef: 'MK-2026-006', details: { file: 'smeta-ПС-600.xlsx' } },
{ daysAgo: 2, userEmail: 'evgeniy@metallcart.ru', action: 'PR_SUBMIT', entityType: 'ORDER', entityRef: 'MK-2026-006', details: { count: 4 } },
{ daysAgo: 1, userEmail: 'anna@metallcart.ru', action: 'ORDER_CREATE', entityType: 'ORDER', entityRef: 'MK-2026-006', details: { orderCode: 'MK-2026-006', productName: 'Полка складская ПС-600' } },
{ daysAgo: 1, userEmail: 'nikita@metallcart.ru', action: 'ORDER_STATUS_CHANGE', entityType: 'ORDER', entityRef: 'MK-2026-006', details: { from: 'VALIDATED', to: 'IN_PRODUCTION' } },
{ daysAgo: 0, userEmail: 'louis@metallcart.ru', action: 'UPD_SIGN', entityType: 'ORDER', entityRef: 'MK-2026-007', details: { orderCode: 'MK-2026-007' } },
];
for (const a of auditDefs) {
const userId = userMap[a.userEmail]?.id;
const entityId = orderMap[a.entityRef]?.id?.toString() ?? a.entityRef;
if (!userId) continue;
const d = new Date(now);
d.setDate(d.getDate() - a.daysAgo);
d.setHours(9 + Math.floor(Math.random() * 8), Math.floor(Math.random() * 60));
await prisma.auditLog.create({
data: { userId, action: a.action, entityType: a.entityType, entityId, details: a.details as any, createdAt: d },
});
}
console.log(`[AUDIT] ${auditDefs.length} audit events created`);
// ─── Step 11: Documents for ACTIVE client contracts ─────────
const louisId = userMap['louis@metallcart.ru']?.id;
for (const cc of contractsData.clientContracts.filter(c => c.status === 'ACTIVE')) {
const contractId = contractRefMap[cc.ref];
if (!contractId || !louisId) continue;
await prisma.document.create({
data: {
documentType: 'CONTRACT',
direction: 'CLIENT',
title: `Договор ${cc.contractNumber}`,
clientContractId: contractId,
responsibleId: louisId,
},
});
}
console.log('[DOCUMENTS] Contract documents created');
}
// ─── Post-seed invariants ──────────────────────────────────────
async function verifyInvariants(prisma: PrismaClient) {
console.log('\n[INVARIANTS] Verifying post-seed state...');
const checks: Array<{ label: string; count: number; min: number }> = [];
checks.push({ label: 'expense_codes', count: await prisma.expenseCode.count(), min: 50 });
checks.push({ label: 'supplier_tags', count: await prisma.supplierTag.count(), min: 7 });
checks.push({ label: 'notification_category_configs', count: await prisma.notificationCategoryConfig.count(), min: 10 });
checks.push({ label: 'public_holidays', count: await prisma.publicHoliday.count(), min: 18 });
checks.push({ label: 'help_articles', count: await prisma.helpArticle.count(), min: 80 });
checks.push({ label: 'payment_templates', count: await prisma.paymentTemplate.count(), min: 5 });
checks.push({ label: 'users', count: await prisma.user.count(), min: 10 });
checks.push({ label: 'clients', count: await prisma.client.count(), min: 8 });
checks.push({ label: 'suppliers', count: await prisma.supplier.count(), min: 7 });
checks.push({ label: 'orders', count: await prisma.order.count(), min: 10 });
checks.push({ label: 'smetas', count: await prisma.smeta.count(), min: 7 });
let allOk = true;
for (const c of checks) {
const ok = c.count >= c.min;
const symbol = ok ? '✓' : '✗';
console.log(` ${symbol} ${c.label}: ${c.count} (min: ${c.min})`);
if (!ok) allOk = false;
}
if (!allOk) {
throw new Error('[INVARIANTS] FAILED — some counts below minimum. Seed may be incomplete.');
}
console.log('[INVARIANTS] ALL OK');
}
// ═══════════════════════════════════════════════════════════════
// RUNNER
// ═══════════════════════════════════════════════════════════════
const url = process.env.DATABASE_URL;
if (!url) {
console.error('DATABASE_URL required. Usage:\n DATABASE_URL="postgresql://...erp_staging..." npx tsx scripts/seed-staging.ts [--dry-run]');
process.exit(1);
}
assertSafeDatabase(url);
if (DRY_RUN) {
console.log('\n[DRY-RUN] Mode enabled — validating JSON data without DB writes...\n');
try {
const users = loadJson<StagingUser[]>('staging-users.json');
console.log(` staging-users.json: ${users.length} entries OK`);
const clients = loadJson<StagingClient[]>('staging-clients.json');
console.log(` staging-clients.json: ${clients.length} entries OK`);
const suppliers = loadJson<StagingSupplier[]>('staging-suppliers.json');
console.log(` staging-suppliers.json: ${suppliers.length} entries OK`);
const contracts = loadJson<ContractData>('staging-contracts.json');
console.log(` staging-contracts.json: ${contracts.clientContracts.length} client + ${contracts.supplierContracts.length} supplier OK`);
const orders = loadJson<OrderDef[]>('staging-orders.json');
console.log(` staging-orders.json: ${orders.length} entries OK`);
const smetas = loadJson<SmetaDef[]>('staging-smetas.json');
console.log(` staging-smetas.json: ${smetas.length} entries OK`);
const prs = loadJson<PrDef[]>('staging-purchase-requirements.json');
console.log(` staging-purchase-requirements.json: ${prs.length} entries OK`);
// Cross-reference validation
const userEmails = new Set(users.map(u => u.email));
const clientAbbrevs = new Set(clients.map(c => c.abbreviation));
const supplierInns = new Set(suppliers.map(s => s.inn));
const contractRefs = new Set(contracts.clientContracts.map(c => c.ref));
let warnings = 0;
for (const o of orders) {
if (!userEmails.has(o.managerEmail)) { console.warn(` WARN: order ${o.orderCode} → unknown manager ${o.managerEmail}`); warnings++; }
if (!clientAbbrevs.has(o.clientAbbrev)) { console.warn(` WARN: order ${o.orderCode} → unknown client ${o.clientAbbrev}`); warnings++; }
if (o.contractRef && !contractRefs.has(o.contractRef)) { console.warn(` WARN: order ${o.orderCode} → unknown contract ${o.contractRef}`); warnings++; }
}
for (const sc of contracts.supplierContracts) {
if (!supplierInns.has(sc.supplierInn)) { console.warn(` WARN: supplier contract ${sc.contractNumber} → unknown supplier ${sc.supplierInn}`); warnings++; }
}
console.log(`\n[DRY-RUN] Validation complete. ${warnings} warnings.`);
if (warnings === 0) console.log('[DRY-RUN] All cross-references OK — safe to run without --dry-run');
} catch (err) {
console.error('[DRY-RUN] FAILED:', err);
process.exit(1);
}
process.exit(0);
}
const prisma = new PrismaClient({ datasources: { db: { url } } });
seedStaging(prisma)
.then(() => verifyInvariants(prisma))
.then(() => {
console.log('\n═══════════════════════════════════════════');
console.log(' STAGING SEED OK ✓');
console.log('═══════════════════════════════════════════');
console.log(' Login: any user@metallcart.ru / MK_Staging_2026!');
console.log(' Référentiels preserved (expense_codes, tags, holidays, help, templates)');
console.log('═══════════════════════════════════════════');
})
.catch((e) => { console.error('[STAGING SEED] FAILED:', e); process.exit(1); })
.finally(() => prisma.$disconnect());