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>
633 lines
33 KiB
TypeScript
633 lines
33 KiB
TypeScript
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());
|