1455 lines
49 KiB
JavaScript
1455 lines
49 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Customer Data Migration Script
|
||
*
|
||
* This script migrates data from multiple source customer master accounts to a single destination account.
|
||
* It updates sub-account parent references rather than moving accounts to avoid username conflicts.
|
||
*
|
||
* Features:
|
||
* - Support multiple source customer IDs or usernames
|
||
* - Converts source customers to admin accounts under destination (default behavior)
|
||
* - Detailed preview with conflict detection
|
||
* - Automatic abort on conflicts using mongo_enhanced.js transaction utilities
|
||
* - Store and append migration results to JSON file for history tracking
|
||
* - Updates sub-accounts' parent references (no username conflicts)
|
||
* - Preserves all references in other collections (JobAssign, JobLog, etc.)
|
||
*
|
||
* Usage:
|
||
* # Preview migration (shows all changes, doesn't make changes)
|
||
* node scripts/migrateCustomerData.js --preview --sources acme_aviation,regional_ag --destination consolidated_ag
|
||
*
|
||
* # With debug output
|
||
* DEBUG=agm:migrate-customer-data node scripts/migrateCustomerData.js --preview --sources acme_aviation --destination consolidated_ag
|
||
*
|
||
* # Execute migration (source customers converted to admin by default)
|
||
* node scripts/migrateCustomerData.js --sources acme_aviation,regional_ag --destination consolidated_ag
|
||
*
|
||
* # Deactivate source customers instead of converting to admin
|
||
* node scripts/migrateCustomerData.js --sources acme_aviation --destination consolidated_ag --deactivate-source
|
||
*
|
||
* Options:
|
||
* --preview Show detailed migration plan without executing
|
||
* --sources Comma-separated list of source customer IDs or usernames
|
||
* --destination Destination customer ID or username
|
||
* --convert-to-admin Convert source customers to admin (kind='2') - DEFAULT
|
||
* --deactivate-source Deactivate source customers instead of converting to admin
|
||
* --reuse-existing-entities Reuse destination's products/crops with matching names
|
||
* --skip-invoices Don't migrate invoice references
|
||
* --output-file Custom output file path (default: ./migration_history.json)
|
||
* --env Custom environment file path (default: ../environment.env)
|
||
* --help Show this help message
|
||
*
|
||
* Example:
|
||
* node scripts/migrateCustomerData.js --preview --sources acme_aviation,regional_ag --destination consolidated_ag
|
||
* node scripts/migrateCustomerData.js --env ../environment_prod.env --sources acme_aviation --destination consolidated_ag
|
||
*/
|
||
|
||
// Load environment variables from environment.env file (or custom path)
|
||
const path = require('path');
|
||
const args = process.argv.slice(2);
|
||
const envArgIndex = args.indexOf('--env');
|
||
const customEnvPath = envArgIndex !== -1 && args[envArgIndex + 1] ? args[envArgIndex + 1] : null;
|
||
const envPath = customEnvPath
|
||
? (path.isAbsolute(customEnvPath) ? customEnvPath : path.join(process.cwd(), customEnvPath))
|
||
: path.join(__dirname, '../environment.env');
|
||
|
||
console.log(`Loading environment from: ${envPath}`);
|
||
require('dotenv').config({ path: envPath });
|
||
|
||
const debug = require('debug')('agm:migrate-customer-data');
|
||
const env = require('../helpers/env');
|
||
const mongoose = require('mongoose');
|
||
const { DBConnection } = require('../helpers/db/connect');
|
||
const mongoEnhanced = require('../helpers/mongo_enhanced');
|
||
const fs = require('fs').promises;
|
||
const { UserTypes } = require('../helpers/constants');
|
||
|
||
// Models
|
||
const models = {
|
||
User: require('../model/user'),
|
||
Customer: require('../model/customer'),
|
||
Client: require('../model/client'),
|
||
Pilot: require('../model/pilot'),
|
||
Vehicle: require('../model/vehicle'),
|
||
Job: require('../model/job'),
|
||
JobLog: require('../model/job_log'),
|
||
JobAssign: require('../model/job_assign'),
|
||
App: require('../model/application'),
|
||
AppFile: require('../model/application_file'),
|
||
AppDetail: require('../model/application_detail'),
|
||
Product: require('../model/product'),
|
||
Crop: require('../model/crop'),
|
||
Invoice: require('../model/invoice'),
|
||
CostingItem: require('../model/costing_items')
|
||
};
|
||
|
||
// Default output file for migration history
|
||
const DEFAULT_OUTPUT_FILE = path.join(__dirname, 'migration_history.json');
|
||
|
||
/**
|
||
* Resolve customer identifiers (username or ID) to customer IDs
|
||
* @param {Array<string>} identifiers - Array of customer usernames or IDs
|
||
* @returns {Promise<Object>} Object with resolved IDs and any errors
|
||
*/
|
||
async function resolveCustomerIdentifiers(identifiers) {
|
||
const resolved = [];
|
||
const errors = [];
|
||
|
||
for (const identifier of identifiers) {
|
||
// Check if it's a valid MongoDB ObjectId format
|
||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(identifier);
|
||
|
||
let customer;
|
||
if (isObjectId) {
|
||
// Try to find by ID
|
||
customer = await models.Customer.findById(identifier).lean();
|
||
if (!customer) {
|
||
errors.push(`Customer ID not found: ${identifier}`);
|
||
}
|
||
} else {
|
||
// Try to find by username (applicator account, kind='1')
|
||
customer = await models.Customer.findOne({
|
||
username: identifier,
|
||
kind: UserTypes.APP
|
||
}).lean();
|
||
|
||
if (!customer) {
|
||
errors.push(`Customer username not found: ${identifier}`);
|
||
}
|
||
}
|
||
|
||
if (customer) {
|
||
resolved.push({
|
||
identifier,
|
||
id: customer._id,
|
||
username: customer.username,
|
||
isUsername: !isObjectId
|
||
});
|
||
}
|
||
}
|
||
|
||
return { resolved, errors };
|
||
}
|
||
|
||
/**
|
||
* Main migration function
|
||
* @param {Array<string>} sourceCustomerIdentifiers - Array of source customer IDs or usernames to migrate from
|
||
* @param {string} targetCustomerIdentifier - Target customer ID or username to migrate to
|
||
* @param {Object} options - Migration options
|
||
* @param {boolean} options.preview - Only show what would be migrated
|
||
* @param {boolean} options.convertToAdmin - Convert source customers to admin accounts under destination (default: true)
|
||
* @param {boolean} options.updateInvoices - Update invoice references
|
||
* @param {string} options.outputFile - Path to output JSON file for history
|
||
*/
|
||
async function migrateCustomerData(sourceCustomerIdentifiers, targetCustomerIdentifier, options = {}) {
|
||
const {
|
||
preview = false,
|
||
convertToAdmin = true, // Default to true
|
||
updateInvoices = true,
|
||
reuseExistingEntities = false, // New option: reuse products/crops with matching names
|
||
outputFile = DEFAULT_OUTPUT_FILE
|
||
} = options;
|
||
|
||
// Resolve identifiers to IDs
|
||
debug('Resolving customer identifiers...');
|
||
|
||
const sourceResolution = await resolveCustomerIdentifiers(sourceCustomerIdentifiers);
|
||
if (sourceResolution.errors.length > 0) {
|
||
throw new Error(`Failed to resolve source customers:\n ${sourceResolution.errors.join('\n ')}`);
|
||
}
|
||
|
||
const targetResolution = await resolveCustomerIdentifiers([targetCustomerIdentifier]);
|
||
if (targetResolution.errors.length > 0) {
|
||
throw new Error(`Failed to resolve target customer:\n ${targetResolution.errors.join('\n ')}`);
|
||
}
|
||
|
||
const sourceCustomerIds = sourceResolution.resolved.map(r => r.id);
|
||
const targetCustomerId = targetResolution.resolved[0].id;
|
||
|
||
// Log what was resolved
|
||
debug('Resolved customers:');
|
||
sourceResolution.resolved.forEach(r => {
|
||
debug(` Source: ${r.identifier} ${r.isUsername ? '(username)' : '(ID)'} -> ${r.id} (${r.username})`);
|
||
});
|
||
const targetInfo = targetResolution.resolved[0];
|
||
debug(` Target: ${targetInfo.identifier} ${targetInfo.isUsername ? '(username)' : '(ID)'} -> ${targetInfo.id} (${targetInfo.username})`);
|
||
|
||
debug(`Starting migration from [${sourceCustomerIds.join(', ')}] to ${targetCustomerId}`);
|
||
debug(`Options: preview=${preview}, convertToAdmin=${convertToAdmin}`);
|
||
|
||
const migrationRecord = {
|
||
timestamp: new Date().toISOString(),
|
||
sourceCustomerIds,
|
||
targetCustomerId,
|
||
sourceIdentifiers: sourceCustomerIdentifiers,
|
||
targetIdentifier: targetCustomerIdentifier,
|
||
options,
|
||
preview,
|
||
status: 'started',
|
||
stats: {
|
||
sourceCustomers: {},
|
||
totalJobs: 0,
|
||
totalClients: 0,
|
||
totalPilots: 0,
|
||
totalVehicles: 0,
|
||
totalOfficers: 0,
|
||
totalInspectors: 0,
|
||
totalProducts: 0,
|
||
totalCrops: 0,
|
||
totalApplications: 0,
|
||
totalInvoices: 0,
|
||
skipped: [],
|
||
conflicts: [],
|
||
errors: []
|
||
},
|
||
details: {
|
||
sources: [],
|
||
conflicts: [],
|
||
changes: []
|
||
}
|
||
};
|
||
|
||
try {
|
||
// Step 1: Validate all customers exist and gather preview data
|
||
debug('Step 1: Validating customers and gathering data...');
|
||
const validationResult = await validateAndGatherData(
|
||
sourceCustomerIds,
|
||
targetCustomerId,
|
||
migrationRecord
|
||
);
|
||
|
||
if (!validationResult.valid) {
|
||
migrationRecord.status = 'validation_failed';
|
||
migrationRecord.error = validationResult.error;
|
||
await saveMigrationHistory(migrationRecord, outputFile);
|
||
throw new Error(validationResult.error);
|
||
}
|
||
|
||
const { sourceCustomers, targetCustomer } = validationResult;
|
||
|
||
// Step 2: Detect conflicts
|
||
debug('Step 2: Detecting conflicts...');
|
||
const conflicts = await detectConflicts(
|
||
sourceCustomers,
|
||
targetCustomer,
|
||
migrationRecord
|
||
);
|
||
|
||
migrationRecord.details.conflicts = conflicts;
|
||
migrationRecord.stats.conflicts = conflicts;
|
||
|
||
// If there are conflicts, abort (e.g., circular references)
|
||
if (conflicts.length > 0) {
|
||
migrationRecord.status = 'aborted_due_to_conflicts';
|
||
await saveMigrationHistory(migrationRecord, outputFile);
|
||
|
||
console.log('\n⚠️ MIGRATION ABORTED - CONFLICTS DETECTED\n');
|
||
console.log('The following conflicts were found:\n');
|
||
displayConflicts(conflicts);
|
||
console.log('\nPlease resolve these conflicts before proceeding\n');
|
||
|
||
throw new Error('Migration aborted due to conflicts.');
|
||
}
|
||
|
||
// Step 2.5: Analyze entity duplication if reuse option is enabled
|
||
if (reuseExistingEntities) {
|
||
debug('Analyzing entity duplication...');
|
||
await analyzeEntityDuplication(
|
||
sourceCustomers,
|
||
targetCustomer,
|
||
migrationRecord
|
||
);
|
||
}
|
||
|
||
// Step 3: Display preview
|
||
console.log('\n' + '='.repeat(80));
|
||
console.log('MIGRATION PREVIEW');
|
||
console.log('='.repeat(80) + '\n');
|
||
displayMigrationPreview(migrationRecord, sourceCustomers, targetCustomer, convertToAdmin, reuseExistingEntities);
|
||
|
||
// If preview mode, save and exit
|
||
if (preview) {
|
||
migrationRecord.status = 'preview_completed';
|
||
await saveMigrationHistory(migrationRecord, outputFile);
|
||
console.log('Preview mode - no changes made\n');
|
||
console.log(`Preview saved to: ${outputFile}\n`);
|
||
return migrationRecord;
|
||
}
|
||
|
||
// Step 4: Execute migration in transaction
|
||
console.log('\n' + '='.repeat(80));
|
||
console.log('EXECUTING MIGRATION');
|
||
console.log('='.repeat(80) + '\n');
|
||
|
||
await executeMigration(
|
||
sourceCustomers,
|
||
targetCustomer,
|
||
conflicts,
|
||
convertToAdmin,
|
||
updateInvoices,
|
||
reuseExistingEntities,
|
||
migrationRecord
|
||
);
|
||
|
||
migrationRecord.status = 'completed';
|
||
migrationRecord.completedAt = new Date().toISOString();
|
||
|
||
console.log('\n✅ Migration completed successfully!\n');
|
||
displayMigrationStats(migrationRecord.stats);
|
||
|
||
} catch (error) {
|
||
migrationRecord.status = 'failed';
|
||
migrationRecord.error = error.message;
|
||
migrationRecord.stack = error.stack;
|
||
migrationRecord.stats.errors.push({
|
||
message: error.message,
|
||
stack: error.stack,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
|
||
// Clear changes since transaction was rolled back
|
||
if (migrationRecord.details && migrationRecord.details.changes) {
|
||
migrationRecord.details.changesRolledBack = migrationRecord.details.changes;
|
||
migrationRecord.details.changes = [];
|
||
migrationRecord.rollbackReason = 'Transaction aborted due to error';
|
||
}
|
||
|
||
debug('Migration failed:', error);
|
||
throw error;
|
||
} finally {
|
||
// Always save migration history
|
||
await saveMigrationHistory(migrationRecord, outputFile);
|
||
console.log(`\nMigration record saved to: ${outputFile}\n`);
|
||
}
|
||
|
||
return migrationRecord;
|
||
}
|
||
|
||
/**
|
||
* Validate customers and gather data for preview
|
||
*/
|
||
async function validateAndGatherData(sourceCustomerIds, targetCustomerId, migrationRecord) {
|
||
// Validate all source customers exist
|
||
const sourceCustomers = await models.Customer.find({
|
||
_id: { $in: sourceCustomerIds }
|
||
}).lean();
|
||
|
||
if (sourceCustomers.length !== sourceCustomerIds.length) {
|
||
const foundIds = sourceCustomers.map(c => c._id.toString());
|
||
const missingIds = sourceCustomerIds.filter(id => !foundIds.includes(id));
|
||
return {
|
||
valid: false,
|
||
error: `Source customer(s) not found: ${missingIds.join(', ')}`
|
||
};
|
||
}
|
||
|
||
// Check if any source customers are already deleted
|
||
const deletedSources = sourceCustomers.filter(c => c.markedDelete);
|
||
if (deletedSources.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: `Cannot migrate from deleted customer(s): ${deletedSources.map(c => c._id).join(', ')}`
|
||
};
|
||
}
|
||
|
||
// Validate target customer exists
|
||
const targetCustomer = await models.Customer.findById(targetCustomerId).lean();
|
||
if (!targetCustomer) {
|
||
return {
|
||
valid: false,
|
||
error: `Target customer not found: ${targetCustomerId}`
|
||
};
|
||
}
|
||
|
||
if (targetCustomer.markedDelete) {
|
||
return {
|
||
valid: false,
|
||
error: `Cannot migrate to deleted customer: ${targetCustomerId}`
|
||
};
|
||
}
|
||
|
||
// Gather statistics for each source
|
||
for (const sourceCustomer of sourceCustomers) {
|
||
const stats = await gatherCustomerStats(sourceCustomer._id);
|
||
migrationRecord.stats.sourceCustomers[sourceCustomer._id] = stats;
|
||
migrationRecord.details.sources.push({
|
||
customerId: sourceCustomer._id,
|
||
username: sourceCustomer.username,
|
||
email: sourceCustomer.email,
|
||
country: sourceCustomer.country,
|
||
stats
|
||
});
|
||
|
||
// Accumulate totals
|
||
migrationRecord.stats.totalJobs += stats.jobs;
|
||
migrationRecord.stats.totalClients += stats.clients;
|
||
migrationRecord.stats.totalPilots += stats.pilots;
|
||
migrationRecord.stats.totalVehicles += stats.vehicles;
|
||
migrationRecord.stats.totalOfficers = (migrationRecord.stats.totalOfficers || 0) + stats.officers;
|
||
migrationRecord.stats.totalInspectors = (migrationRecord.stats.totalInspectors || 0) + stats.inspectors;
|
||
migrationRecord.stats.totalProducts += stats.products;
|
||
migrationRecord.stats.totalCrops += stats.crops;
|
||
migrationRecord.stats.totalApplications += stats.applications;
|
||
migrationRecord.stats.totalInvoices += stats.invoices;
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
sourceCustomers,
|
||
targetCustomer
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Gather statistics for a customer
|
||
*/
|
||
async function gatherCustomerStats(customerId) {
|
||
const [
|
||
clients,
|
||
pilots,
|
||
vehicles,
|
||
officers,
|
||
inspectors,
|
||
partnerSystemUsers,
|
||
jobs,
|
||
products,
|
||
crops
|
||
] = await Promise.all([
|
||
models.Client.find({ parent: customerId }).lean(),
|
||
models.Pilot.find({ parent: customerId }).lean(),
|
||
models.Vehicle.find({ parent: customerId }).lean(),
|
||
models.User.find({ parent: customerId, kind: UserTypes.OFFICER }).lean(),
|
||
models.User.find({ parent: customerId, kind: UserTypes.INSPECTOR }).lean(),
|
||
models.User.find({ parent: customerId, kind: UserTypes.PARTNER_SYSTEM_USER }).lean(),
|
||
models.Job.find({ byPuid: customerId }).lean(),
|
||
models.Product.find({ byPuid: customerId }).lean(),
|
||
models.Crop.find({ byPuid: customerId }).lean()
|
||
]);
|
||
|
||
// Count applications for these jobs
|
||
const jobIds = jobs.map(j => j._id);
|
||
const applications = jobIds.length > 0
|
||
? await models.App.countDocuments({ jobId: { $in: jobIds } })
|
||
: 0;
|
||
|
||
// Count invoices referencing these jobs
|
||
const invoices = jobIds.length > 0
|
||
? await models.Invoice.countDocuments({ 'jobs.job': { $in: jobIds } })
|
||
: 0;
|
||
|
||
return {
|
||
clients: clients.length,
|
||
clientList: clients.map(c => ({ _id: c._id, username: c.username, email: c.email })),
|
||
pilots: pilots.length,
|
||
pilotList: pilots.map(p => ({ _id: p._id, username: p.username, email: p.email })),
|
||
vehicles: vehicles.length,
|
||
vehicleList: vehicles.map(v => ({ _id: v._id, username: v.username, model: v.model })),
|
||
officers: officers.length,
|
||
officerList: officers.map(o => ({ _id: o._id, username: o.username, email: o.email })),
|
||
inspectors: inspectors.length,
|
||
inspectorList: inspectors.map(i => ({ _id: i._id, username: i.username, email: i.email })),
|
||
partnerSystemUsers: partnerSystemUsers.length,
|
||
partnerSystemUserList: partnerSystemUsers.map(p => ({ _id: p._id, username: p.username, partnerUsername: p.partnerUsername })),
|
||
jobs: jobs.length,
|
||
products: products.length,
|
||
productList: products.map(p => ({ _id: p._id, name: p.name })),
|
||
crops: crops.length,
|
||
cropList: crops.map(c => ({ _id: c._id, name: c.name })),
|
||
applications,
|
||
invoices
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Detect conflicts between source customers and target customer
|
||
* NOTE: With the new approach of just updating .parent references,
|
||
* username conflicts are no longer an issue since usernames remain unique globally.
|
||
* This function now just validates that we're not creating circular references.
|
||
*/
|
||
async function detectConflicts(sourceCustomers, targetCustomer, migrationRecord) {
|
||
const conflicts = [];
|
||
|
||
// Check if any source customer is the same as target (would create circular reference)
|
||
for (const sourceCustomer of sourceCustomers) {
|
||
if (sourceCustomer._id.toString() === targetCustomer._id.toString()) {
|
||
conflicts.push({
|
||
type: 'circular_reference',
|
||
message: `Cannot migrate customer ${sourceCustomer.username} to itself`,
|
||
sourceCustomerId: sourceCustomer._id,
|
||
targetCustomerId: targetCustomer._id
|
||
});
|
||
}
|
||
}
|
||
|
||
// Note: Username conflicts are no longer checked because sub-accounts just get their
|
||
// parent reference updated - they keep their original usernames which are globally unique
|
||
|
||
return conflicts;
|
||
}
|
||
|
||
/**
|
||
* Analyze entity duplication when --reuse-existing-entities is enabled
|
||
* This helps users understand what will be reused vs migrated during preview
|
||
*/
|
||
async function analyzeEntityDuplication(sourceCustomers, targetCustomer, migrationRecord) {
|
||
// Get destination customer's products and crops
|
||
const destProducts = await models.Product.find({ byPuid: targetCustomer._id }).lean();
|
||
const destCrops = await models.Crop.find({ byPuid: targetCustomer._id }).lean();
|
||
|
||
// Create lookup maps by name (case-insensitive, trimmed)
|
||
const destProductsByName = {};
|
||
const destCropsByName = {};
|
||
|
||
destProducts.forEach(p => {
|
||
if (p.name) {
|
||
destProductsByName[p.name.toLowerCase().trim()] = p;
|
||
}
|
||
});
|
||
|
||
destCrops.forEach(c => {
|
||
if (c.name) {
|
||
destCropsByName[c.name.toLowerCase().trim()] = c;
|
||
}
|
||
});
|
||
|
||
// Analyze each source customer
|
||
for (const sourceCustomer of sourceCustomers) {
|
||
const sourceProducts = await models.Product.find({ byPuid: sourceCustomer._id }).lean();
|
||
const sourceCrops = await models.Crop.find({ byPuid: sourceCustomer._id }).lean();
|
||
|
||
const productMatches = [];
|
||
const productNoMatches = [];
|
||
const cropMatches = [];
|
||
const cropNoMatches = [];
|
||
|
||
// Check products for duplicates
|
||
sourceProducts.forEach(p => {
|
||
const productName = p.name ? p.name.toLowerCase().trim() : '';
|
||
if (productName && destProductsByName[productName]) {
|
||
productMatches.push({
|
||
sourceId: p._id,
|
||
sourceName: p.name,
|
||
destId: destProductsByName[productName]._id,
|
||
destName: destProductsByName[productName].name
|
||
});
|
||
} else {
|
||
productNoMatches.push({
|
||
id: p._id,
|
||
name: p.name
|
||
});
|
||
}
|
||
});
|
||
|
||
// Check crops for duplicates
|
||
sourceCrops.forEach(c => {
|
||
const cropName = c.name ? c.name.toLowerCase().trim() : '';
|
||
if (cropName && destCropsByName[cropName]) {
|
||
cropMatches.push({
|
||
sourceId: c._id,
|
||
sourceName: c.name,
|
||
destId: destCropsByName[cropName]._id,
|
||
destName: destCropsByName[cropName].name
|
||
});
|
||
} else {
|
||
cropNoMatches.push({
|
||
id: c._id,
|
||
name: c.name
|
||
});
|
||
}
|
||
});
|
||
|
||
// Store analysis in migration record
|
||
if (!migrationRecord.details.entityAnalysis) {
|
||
migrationRecord.details.entityAnalysis = {};
|
||
}
|
||
|
||
migrationRecord.details.entityAnalysis[sourceCustomer._id] = {
|
||
products: {
|
||
total: sourceProducts.length,
|
||
willReuse: productMatches.length,
|
||
willMigrate: productNoMatches.length,
|
||
matches: productMatches,
|
||
noMatches: productNoMatches
|
||
},
|
||
crops: {
|
||
total: sourceCrops.length,
|
||
willReuse: cropMatches.length,
|
||
willMigrate: cropNoMatches.length,
|
||
matches: cropMatches,
|
||
noMatches: cropNoMatches
|
||
}
|
||
};
|
||
}
|
||
|
||
debug('Entity duplication analysis completed');
|
||
}
|
||
|
||
/**
|
||
* Execute the migration in a transaction
|
||
*/
|
||
async function executeMigration(
|
||
sourceCustomers,
|
||
targetCustomer,
|
||
conflicts,
|
||
convertToAdmin,
|
||
updateInvoices,
|
||
reuseExistingEntities,
|
||
migrationRecord
|
||
) {
|
||
// Use enhanced transaction from mongo_enhanced.js
|
||
await mongoEnhanced.enhancedRunInTransaction(async (session) => {
|
||
debug('Starting migration transaction...');
|
||
|
||
// Process each source customer
|
||
for (const sourceCustomer of sourceCustomers) {
|
||
console.log(`\nMigrating data from customer: ${sourceCustomer.username} (${sourceCustomer._id})`);
|
||
|
||
// 1. Delete partner system users and backup for rollback
|
||
await deletePartnerSystemUsers(sourceCustomer._id, session, migrationRecord);
|
||
|
||
// 2. Migrate or convert source customer account if requested
|
||
if (convertToAdmin) {
|
||
await convertCustomerToAdmin(sourceCustomer._id, targetCustomer._id, session, migrationRecord);
|
||
}
|
||
|
||
// 3. Update sub-accounts' parent reference (no merging needed - usernames are globally unique)
|
||
await updateSubAccountsParent(
|
||
sourceCustomer._id,
|
||
targetCustomer._id,
|
||
session,
|
||
migrationRecord
|
||
);
|
||
|
||
// 3. Migrate entities (products, crops)
|
||
await migrateEntities(sourceCustomer._id, targetCustomer._id, session, reuseExistingEntities, migrationRecord);
|
||
|
||
// 4. Migrate jobs and related data
|
||
const migratedJobIds = await migrateJobs(sourceCustomer._id, targetCustomer._id, session, migrationRecord);
|
||
|
||
// 5. Migrate billing data
|
||
if (updateInvoices && migratedJobIds && migratedJobIds.length > 0) {
|
||
await migrateBillingData(migratedJobIds, targetCustomer._id, session, migrationRecord);
|
||
}
|
||
|
||
// 6. Mark source customer as inactive (unless converted to admin)
|
||
if (!convertToAdmin) {
|
||
await deactivateSourceCustomer(sourceCustomer, session, migrationRecord);
|
||
}
|
||
|
||
console.log(`✓ Completed migration for customer: ${sourceCustomer.username}`);
|
||
}
|
||
|
||
debug('Migration transaction completed successfully');
|
||
}, mongoEnhanced.DEFAULT_TRANSACTION_OPTIONS);
|
||
}
|
||
|
||
/**
|
||
* Convert source customer to admin account under target customer
|
||
* Instead of creating a new user, we convert the existing customer account to admin type
|
||
* This preserves all references in other collections (JobAssign, JobLog, etc.)
|
||
*/
|
||
async function convertCustomerToAdmin(sourceCustomerId, targetCustomerId, session, migrationRecord) {
|
||
debug(`Converting customer ${sourceCustomerId} to admin under ${targetCustomerId}`);
|
||
|
||
const sourceCustomer = await models.Customer.findById(sourceCustomerId).session(session).lean();
|
||
if (!sourceCustomer) {
|
||
throw new Error(`Source customer ${sourceCustomerId} not found`);
|
||
}
|
||
|
||
// Store original values for rollback
|
||
const originalKind = sourceCustomer.kind;
|
||
const originalParent = sourceCustomer.parent;
|
||
|
||
// Use direct MongoDB update to bypass Mongoose discriminator protection
|
||
// This changes the customer account from APP (kind='1') to APP_ADM (kind='2')
|
||
const result = await models.Customer.collection.updateOne(
|
||
{ _id: sourceCustomer._id },
|
||
{
|
||
$set: {
|
||
kind: UserTypes.APP_ADM, // Change from APP (1) to APP_ADM (2)
|
||
parent: targetCustomerId,
|
||
active: true // Keep it active
|
||
}
|
||
},
|
||
{ session }
|
||
);
|
||
|
||
if (result.matchedCount === 0) {
|
||
throw new Error(`Failed to update customer ${sourceCustomerId}`);
|
||
}
|
||
|
||
migrationRecord.stats.skipped.push({
|
||
type: 'customer_converted_to_admin',
|
||
customerId: sourceCustomerId,
|
||
username: sourceCustomer.username,
|
||
originalKind: originalKind,
|
||
newKind: UserTypes.APP_ADM
|
||
});
|
||
|
||
migrationRecord.details.changes.push({
|
||
action: 'convert_customer_to_admin',
|
||
customerId: sourceCustomerId,
|
||
username: sourceCustomer.username,
|
||
originalKind: originalKind,
|
||
originalParent: originalParent,
|
||
newKind: UserTypes.APP_ADM,
|
||
newParent: targetCustomerId
|
||
});
|
||
|
||
console.log(` → Converted customer to admin: ${sourceCustomer.username} (kind: ${originalKind} → ${UserTypes.APP_ADM})`);
|
||
}
|
||
|
||
/**
|
||
* Update sub-accounts' parent reference to point to the destination customer
|
||
* Since usernames are globally unique, no conflicts occur - we just update the parent field
|
||
*/
|
||
/**
|
||
* Delete partner system users and backup for rollback
|
||
* Partner system users (kind='21') should not be migrated to the destination customer
|
||
*/
|
||
async function deletePartnerSystemUsers(sourceId, session, migrationRecord) {
|
||
debug('Deleting partner system users...');
|
||
|
||
// Get all partner system users from source customer
|
||
const partnerSystemUsers = await models.User.find({
|
||
parent: sourceId,
|
||
kind: UserTypes.PARTNER_SYSTEM_USER
|
||
}).session(session);
|
||
|
||
if (partnerSystemUsers.length === 0) {
|
||
debug('No partner system users to delete');
|
||
return;
|
||
}
|
||
|
||
const deletedUsers = [];
|
||
|
||
for (const user of partnerSystemUsers) {
|
||
console.log(` → Deleting partner system user: ${user.username || user.partnerUsername}`);
|
||
|
||
// Backup full user data for rollback
|
||
const userData = user.toObject();
|
||
delete userData.__v; // Remove version key
|
||
deletedUsers.push(userData);
|
||
|
||
// Delete the user
|
||
await models.User.deleteOne({ _id: user._id }, { session });
|
||
}
|
||
|
||
console.log(` → Deleted ${deletedUsers.length} partner system users`);
|
||
|
||
// Track deletion in migration record for rollback
|
||
if (deletedUsers.length > 0) {
|
||
migrationRecord.details.changes.push({
|
||
action: 'delete_partner_system_users',
|
||
sourceCustomerId: sourceId,
|
||
count: deletedUsers.length,
|
||
deletedUsers: deletedUsers // Full backup for restoration
|
||
});
|
||
}
|
||
|
||
debug(`Deleted ${deletedUsers.length} partner system users`);
|
||
}
|
||
|
||
/**
|
||
* Update sub-accounts' parent references to point to target customer
|
||
*/
|
||
async function updateSubAccountsParent(sourceId, targetId, session, migrationRecord) {
|
||
debug('Updating sub-accounts parent references...');
|
||
|
||
// Get all sub-accounts from source
|
||
const subAccounts = await models.User.find({
|
||
parent: sourceId,
|
||
kind: { $in: [UserTypes.CLIENT, UserTypes.PILOT, UserTypes.DEVICE, UserTypes.OFFICER, UserTypes.INSPECTOR] }
|
||
}).session(session);
|
||
|
||
let clientCount = 0;
|
||
let pilotCount = 0;
|
||
let vehicleCount = 0;
|
||
let officerCount = 0;
|
||
let inspectorCount = 0;
|
||
|
||
for (const account of subAccounts) {
|
||
// Get display name - handle potential corrupt data
|
||
let displayName = 'unnamed';
|
||
if (account.username && typeof account.username === 'string') {
|
||
displayName = account.username;
|
||
} else if (account.model && typeof account.model === 'string') {
|
||
displayName = account.model;
|
||
} else if (account.email && typeof account.email === 'string') {
|
||
displayName = account.email;
|
||
}
|
||
|
||
console.log(` → Updating ${getKindName(account.kind)} parent: ${displayName}`);
|
||
|
||
// Simply update the parent reference
|
||
account.parent = targetId;
|
||
await account.save({ session });
|
||
|
||
// Track counts
|
||
if (account.kind === UserTypes.CLIENT) clientCount++;
|
||
else if (account.kind === UserTypes.PILOT) pilotCount++;
|
||
else if (account.kind === UserTypes.DEVICE) vehicleCount++;
|
||
else if (account.kind === UserTypes.OFFICER) officerCount++;
|
||
else if (account.kind === UserTypes.INSPECTOR) inspectorCount++;
|
||
|
||
migrationRecord.details.changes.push({
|
||
action: 'update_parent_reference',
|
||
kind: account.kind,
|
||
username: account.username,
|
||
name: account.name || account?.contact || null,
|
||
accountId: account._id,
|
||
fromParent: sourceId,
|
||
toParent: targetId
|
||
});
|
||
}
|
||
|
||
console.log(` → Updated ${clientCount} clients, ${pilotCount} pilots, ${vehicleCount} vehicles, ${officerCount} officers, ${inspectorCount} inspectors`);
|
||
|
||
debug(`Updated parent references for ${subAccounts.length} sub-accounts`);
|
||
}
|
||
|
||
/**
|
||
* Helper to get kind name for display
|
||
*/
|
||
function getKindName(kind) {
|
||
// Convert to string for comparison since UserTypes are strings
|
||
const kindStr = typeof kind === 'string' ? kind : String(kind);
|
||
|
||
switch (kindStr) {
|
||
case '3': return 'Client'; // UserTypes.CLIENT
|
||
case '5': return 'Pilot'; // UserTypes.PILOT
|
||
case '9': return 'Vehicle'; // UserTypes.DEVICE
|
||
case '4': return 'Officer'; // UserTypes.OFFICER
|
||
case '6': return 'Inspector'; // UserTypes.INSPECTOR
|
||
case '21': return 'PartnerSystemUser'; // UserTypes.PARTNER_SYSTEM_USER
|
||
default: return 'Account';
|
||
}
|
||
}/**
|
||
* Migrate entities (products, crops)
|
||
*/
|
||
async function migrateEntities(sourceId, targetId, session, reuseExistingEntities, migrationRecord) {
|
||
debug('Migrating entities...');
|
||
|
||
// Migrate products - track each product ID
|
||
const sourceProducts = await models.Product.find({ byPuid: sourceId }).session(session);
|
||
const productIds = [];
|
||
const productIdMap = {}; // Map from source product ID to destination product ID
|
||
const deletedProducts = []; // Store deleted products for rollback
|
||
let reusedProductCount = 0;
|
||
let migratedProductCount = 0;
|
||
|
||
if (reuseExistingEntities) {
|
||
// Get existing products in destination
|
||
const destProducts = await models.Product.find({ byPuid: targetId }).session(session).lean();
|
||
const destProductsByName = {};
|
||
destProducts.forEach(p => {
|
||
if (p.name) {
|
||
destProductsByName[p.name.toLowerCase().trim()] = p;
|
||
}
|
||
});
|
||
|
||
for (const product of sourceProducts) {
|
||
const productName = product.name ? product.name.toLowerCase().trim() : '';
|
||
const existingProduct = destProductsByName[productName];
|
||
|
||
if (existingProduct && productName) {
|
||
// Reuse existing product - track the mapping
|
||
productIdMap[product._id.toString()] = existingProduct._id.toString();
|
||
reusedProductCount++;
|
||
|
||
// Store the product data before deleting (for rollback)
|
||
deletedProducts.push(product.toObject());
|
||
|
||
// Delete the source product since we're using the destination's
|
||
await models.Product.deleteOne({ _id: product._id }).session(session);
|
||
} else {
|
||
// Migrate product to destination
|
||
productIds.push(product._id.toString());
|
||
productIdMap[product._id.toString()] = product._id.toString();
|
||
product.byPuid = targetId;
|
||
await product.save({ session });
|
||
migratedProductCount++;
|
||
}
|
||
}
|
||
|
||
console.log(` → Migrated ${migratedProductCount} products, reused ${reusedProductCount} existing`);
|
||
} else {
|
||
// Original behavior: migrate all products
|
||
for (const product of sourceProducts) {
|
||
productIds.push(product._id.toString());
|
||
productIdMap[product._id.toString()] = product._id.toString();
|
||
product.byPuid = targetId;
|
||
await product.save({ session });
|
||
}
|
||
migratedProductCount = sourceProducts.length;
|
||
console.log(` → Migrated ${migratedProductCount} products`);
|
||
}
|
||
|
||
if (sourceProducts.length > 0) {
|
||
migrationRecord.details.changes.push({
|
||
action: 'migrate_products',
|
||
count: migratedProductCount,
|
||
reusedCount: reusedProductCount,
|
||
productIds: productIds,
|
||
productIdMap: reuseExistingEntities ? productIdMap : undefined,
|
||
deletedProducts: deletedProducts.length > 0 ? deletedProducts : undefined,
|
||
oldParent: sourceId,
|
||
newParent: targetId
|
||
});
|
||
}
|
||
|
||
// Migrate crops - track each crop ID
|
||
const sourceCrops = await models.Crop.find({ byPuid: sourceId }).session(session);
|
||
const cropIds = [];
|
||
const cropIdMap = {}; // Map from source crop ID to destination crop ID
|
||
const deletedCrops = []; // Store deleted crops for rollback
|
||
let reusedCropCount = 0;
|
||
let migratedCropCount = 0;
|
||
|
||
if (reuseExistingEntities) {
|
||
// Get existing crops in destination
|
||
const destCrops = await models.Crop.find({ byPuid: targetId }).session(session).lean();
|
||
const destCropsByName = {};
|
||
destCrops.forEach(c => {
|
||
if (c.name) {
|
||
destCropsByName[c.name.toLowerCase().trim()] = c;
|
||
}
|
||
});
|
||
|
||
for (const crop of sourceCrops) {
|
||
const cropName = crop.name ? crop.name.toLowerCase().trim() : '';
|
||
const existingCrop = destCropsByName[cropName];
|
||
|
||
if (existingCrop && cropName) {
|
||
// Reuse existing crop - track the mapping
|
||
cropIdMap[crop._id.toString()] = existingCrop._id.toString();
|
||
reusedCropCount++;
|
||
|
||
// Store the crop data before deleting (for rollback)
|
||
deletedCrops.push(crop.toObject());
|
||
|
||
// Delete the source crop since we're using the destination's
|
||
await models.Crop.deleteOne({ _id: crop._id }).session(session);
|
||
} else {
|
||
// Migrate crop to destination
|
||
cropIds.push(crop._id.toString());
|
||
cropIdMap[crop._id.toString()] = crop._id.toString();
|
||
crop.byPuid = targetId;
|
||
await crop.save({ session });
|
||
migratedCropCount++;
|
||
}
|
||
}
|
||
|
||
console.log(` → Migrated ${migratedCropCount} crops, reused ${reusedCropCount} existing`);
|
||
} else {
|
||
// Original behavior: migrate all crops
|
||
for (const crop of sourceCrops) {
|
||
cropIds.push(crop._id.toString());
|
||
cropIdMap[crop._id.toString()] = crop._id.toString();
|
||
crop.byPuid = targetId;
|
||
await crop.save({ session });
|
||
}
|
||
migratedCropCount = sourceCrops.length;
|
||
console.log(` → Migrated ${migratedCropCount} crops`);
|
||
}
|
||
|
||
if (sourceCrops.length > 0) {
|
||
migrationRecord.details.changes.push({
|
||
action: 'migrate_crops',
|
||
count: migratedCropCount,
|
||
reusedCount: reusedCropCount,
|
||
cropIds: cropIds,
|
||
cropIdMap: reuseExistingEntities ? cropIdMap : undefined,
|
||
deletedCrops: deletedCrops.length > 0 ? deletedCrops : undefined,
|
||
oldParent: sourceId,
|
||
newParent: targetId
|
||
});
|
||
}
|
||
|
||
// Migrate costing items - track each item ID
|
||
const costingItems = await models.CostingItem.find({ byPuid: sourceId }).session(session);
|
||
const costingItemIds = [];
|
||
|
||
for (const item of costingItems) {
|
||
costingItemIds.push(item._id.toString());
|
||
item.byPuid = targetId;
|
||
await item.save({ session });
|
||
}
|
||
|
||
if (costingItems.length > 0) {
|
||
console.log(` → Migrated ${costingItems.length} costing items`);
|
||
|
||
migrationRecord.details.changes.push({
|
||
action: 'migrate_costing_items',
|
||
count: costingItems.length,
|
||
costingItemIds: costingItemIds,
|
||
oldParent: sourceId,
|
||
newParent: targetId
|
||
});
|
||
}
|
||
|
||
debug(`Migrated products: ${migratedProductCount}, crops: ${migratedCropCount}`);
|
||
}
|
||
|
||
/**
|
||
* Migrate jobs and all related data
|
||
*/
|
||
async function migrateJobs(sourceId, targetId, session, migrationRecord) {
|
||
debug('Migrating jobs and applications...');
|
||
|
||
// Get all jobs from source customer
|
||
const jobs = await models.Job.find({ byPuid: sourceId }).session(session);
|
||
console.log(` → Migrating ${jobs.length} jobs...`);
|
||
|
||
const jobIds = [];
|
||
let appCount = 0;
|
||
|
||
for (const job of jobs) {
|
||
jobIds.push(job._id.toString());
|
||
|
||
// Update job byPuid
|
||
job.byPuid = targetId;
|
||
await job.save({ session });
|
||
|
||
// Count applications for this job (no need to update as they reference jobId)
|
||
const applications = await models.App.countDocuments({ jobId: job._id }).session(session);
|
||
appCount += applications;
|
||
}
|
||
|
||
console.log(` → Migrated ${jobs.length} jobs with ${appCount} applications`);
|
||
|
||
if (jobs.length > 0) {
|
||
migrationRecord.details.changes.push({
|
||
action: 'migrate_jobs',
|
||
jobCount: jobs.length,
|
||
jobIds: jobIds,
|
||
applicationCount: appCount,
|
||
oldParent: sourceId,
|
||
newParent: targetId
|
||
});
|
||
}
|
||
|
||
debug(`Migrated ${jobs.length} jobs with ${appCount} applications`);
|
||
|
||
// Return the job IDs for use in invoice migration
|
||
return jobIds;
|
||
}
|
||
|
||
/**
|
||
* Migrate billing and invoice data
|
||
*/
|
||
async function migrateBillingData(jobIds, targetId, session, migrationRecord) {
|
||
debug('Migrating billing data...');
|
||
|
||
if (!jobIds || jobIds.length === 0) {
|
||
debug('No jobs found, skipping invoice migration');
|
||
return;
|
||
}
|
||
|
||
// Find and update invoices that reference these jobs - track each invoice
|
||
const invoices = await models.Invoice.find({
|
||
'jobs.job': { $in: jobIds }
|
||
}).session(session);
|
||
|
||
const invoiceIds = [];
|
||
const oldCustomerIds = [];
|
||
|
||
for (const invoice of invoices) {
|
||
invoiceIds.push(invoice._id.toString());
|
||
oldCustomerIds.push(invoice.byPuid ? invoice.byPuid.toString() : null);
|
||
|
||
invoice.byPuid = targetId;
|
||
await invoice.save({ session });
|
||
}
|
||
|
||
console.log(` → Migrated ${invoices.length} invoices`);
|
||
|
||
if (invoices.length > 0) {
|
||
migrationRecord.details.changes.push({
|
||
action: 'migrate_invoices',
|
||
count: invoices.length,
|
||
invoiceIds: invoiceIds,
|
||
oldCustomerIds: oldCustomerIds,
|
||
newParent: targetId
|
||
});
|
||
}
|
||
|
||
debug(`Migrated ${invoices.length} invoices`);
|
||
}
|
||
|
||
/**
|
||
* Deactivate source customer after migration
|
||
*/
|
||
async function deactivateSourceCustomer(sourceCustomer, session, migrationRecord) {
|
||
debug(`Deactivating source customer ${sourceCustomer._id}`);
|
||
|
||
const customer = await models.Customer.findById(sourceCustomer._id).session(session);
|
||
if (customer) {
|
||
customer.active = false;
|
||
customer.markedDelete = true;
|
||
customer.username = customer.username + '#migrated_' + Date.now();
|
||
await customer.save({ session });
|
||
|
||
migrationRecord.details.changes.push({
|
||
action: 'deactivate_source_customer',
|
||
customerId: sourceCustomer._id,
|
||
oldUsername: sourceCustomer.username,
|
||
newUsername: customer.username
|
||
});
|
||
|
||
console.log(` → Deactivated source customer: ${sourceCustomer.username}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save migration history to JSON file
|
||
*/
|
||
async function saveMigrationHistory(migrationRecord, outputFile) {
|
||
try {
|
||
let history = [];
|
||
|
||
// Try to read existing history
|
||
try {
|
||
const existingData = await fs.readFile(outputFile, 'utf8');
|
||
history = JSON.parse(existingData);
|
||
if (!Array.isArray(history)) {
|
||
history = [history];
|
||
}
|
||
} catch (error) {
|
||
// File doesn't exist or is invalid, start fresh
|
||
debug('Starting new migration history file');
|
||
}
|
||
|
||
// Append new record
|
||
history.push(migrationRecord);
|
||
|
||
// Write back to file
|
||
await fs.writeFile(outputFile, JSON.stringify(history, null, 2), 'utf8');
|
||
debug(`Migration history saved to ${outputFile}`);
|
||
} catch (error) {
|
||
debug('Error saving migration history:', error);
|
||
// Don't throw - this is a logging operation
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Display migration preview
|
||
*/
|
||
function displayMigrationPreview(migrationRecord, sourceCustomers, targetCustomer, convertToAdmin, reuseExistingEntities) {
|
||
console.log('Source Customers:');
|
||
sourceCustomers.forEach(source => {
|
||
const stats = migrationRecord.stats.sourceCustomers[source._id];
|
||
console.log(` • ${source.username} (${source._id})`);
|
||
console.log(` Email: ${source.email || 'N/A'}`);
|
||
console.log(` Country: ${source.country}`);
|
||
console.log(` Clients: ${stats.clients}`);
|
||
console.log(` Pilots: ${stats.pilots}`);
|
||
console.log(` Vehicles: ${stats.vehicles}`);
|
||
console.log(` Officers: ${stats.officers}`);
|
||
console.log(` Inspectors: ${stats.inspectors}`);
|
||
if (stats.partnerSystemUsers > 0) {
|
||
console.log(` Partner System Users: ${stats.partnerSystemUsers} (will be deleted)`);
|
||
}
|
||
console.log(` Jobs: ${stats.jobs}`);
|
||
console.log(` Products: ${stats.products}`);
|
||
console.log(` Crops: ${stats.crops}`);
|
||
console.log(` Applications: ${stats.applications}`);
|
||
console.log(` Invoices: ${stats.invoices}`);
|
||
|
||
// Show entity reuse analysis if enabled
|
||
if (reuseExistingEntities && migrationRecord.details.entityAnalysis) {
|
||
const analysis = migrationRecord.details.entityAnalysis[source._id];
|
||
if (analysis) {
|
||
if (analysis.products.willReuse > 0 || analysis.crops.willReuse > 0) {
|
||
console.log(` ⚠️ Entity Reuse:`);
|
||
if (analysis.products.willReuse > 0) {
|
||
console.log(` - Products: ${analysis.products.willMigrate} will migrate, ${analysis.products.willReuse} will reuse existing`);
|
||
}
|
||
if (analysis.crops.willReuse > 0) {
|
||
console.log(` - Crops: ${analysis.crops.willMigrate} will migrate, ${analysis.crops.willReuse} will reuse existing`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (convertToAdmin) {
|
||
console.log(` ⚠️ Will be converted to ADMIN account under destination`);
|
||
} else {
|
||
console.log(` ⚠️ Will be DEACTIVATED after migration`);
|
||
}
|
||
console.log('');
|
||
});
|
||
|
||
console.log('Destination Customer:');
|
||
console.log(` • ${targetCustomer.username} (${targetCustomer._id})`);
|
||
console.log(` Email: ${targetCustomer.email || 'N/A'}`);
|
||
console.log(` Country: ${targetCustomer.country}`);
|
||
console.log('');
|
||
|
||
console.log('Migration Summary:');
|
||
console.log(` Total Clients: ${migrationRecord.stats.totalClients}`);
|
||
console.log(` Total Pilots: ${migrationRecord.stats.totalPilots}`);
|
||
console.log(` Total Vehicles: ${migrationRecord.stats.totalVehicles}`);
|
||
console.log(` Total Officers: ${migrationRecord.stats.totalOfficers || 0}`);
|
||
console.log(` Total Inspectors: ${migrationRecord.stats.totalInspectors || 0}`);
|
||
console.log(` Total Jobs: ${migrationRecord.stats.totalJobs}`);
|
||
console.log(` Total Products: ${migrationRecord.stats.totalProducts}`);
|
||
console.log(` Total Crops: ${migrationRecord.stats.totalCrops}`);
|
||
console.log(` Total Applications: ${migrationRecord.stats.totalApplications}`);
|
||
console.log(` Total Invoices: ${migrationRecord.stats.totalInvoices}`);
|
||
|
||
// Show detailed entity reuse summary if enabled
|
||
if (reuseExistingEntities && migrationRecord.details.entityAnalysis) {
|
||
console.log('');
|
||
console.log('Entity Reuse Summary (--reuse-existing-entities enabled):');
|
||
|
||
let totalProductMatches = 0;
|
||
let totalCropMatches = 0;
|
||
const allProductMatches = [];
|
||
const allCropMatches = [];
|
||
|
||
Object.entries(migrationRecord.details.entityAnalysis).forEach(([customerId, analysis]) => {
|
||
totalProductMatches += analysis.products.willReuse;
|
||
totalCropMatches += analysis.crops.willReuse;
|
||
|
||
if (analysis.products.matches.length > 0) {
|
||
allProductMatches.push(...analysis.products.matches.map(m => ({
|
||
...m,
|
||
customerId
|
||
})));
|
||
}
|
||
if (analysis.crops.matches.length > 0) {
|
||
allCropMatches.push(...analysis.crops.matches.map(m => ({
|
||
...m,
|
||
customerId
|
||
})));
|
||
}
|
||
});
|
||
|
||
if (totalProductMatches > 0) {
|
||
console.log(` Products with matching names: ${totalProductMatches} will be reused (source deleted)`);
|
||
allProductMatches.forEach(match => {
|
||
console.log(` - "${match.sourceName}" → will use destination's "${match.destName}"`);
|
||
});
|
||
}
|
||
if (totalCropMatches > 0) {
|
||
console.log(` Crops with matching names: ${totalCropMatches} will be reused (source deleted)`);
|
||
allCropMatches.forEach(match => {
|
||
console.log(` - "${match.sourceName}" → will use destination's "${match.destName}"`);
|
||
});
|
||
}
|
||
|
||
if (totalProductMatches === 0 && totalCropMatches === 0) {
|
||
console.log(` No duplicate products or crops found - all will be migrated`);
|
||
}
|
||
}
|
||
|
||
console.log('');
|
||
}
|
||
|
||
/**
|
||
* Display conflicts
|
||
*/
|
||
function displayConflicts(conflicts) {
|
||
conflicts.forEach((conflict, idx) => {
|
||
console.log(` ${idx + 1}. ${conflict.type}: ${conflict.message}`);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Display migration statistics
|
||
*/
|
||
function displayMigrationStats(stats) {
|
||
console.log('Migration Statistics:');
|
||
console.log(` Clients: ${stats.totalClients}`);
|
||
console.log(` Pilots: ${stats.totalPilots}`);
|
||
console.log(` Vehicles: ${stats.totalVehicles}`);
|
||
console.log(` Officers: ${stats.totalOfficers || 0}`);
|
||
console.log(` Inspectors: ${stats.totalInspectors || 0}`);
|
||
console.log(` Jobs: ${stats.totalJobs}`);
|
||
console.log(` Products: ${stats.totalProducts}`);
|
||
console.log(` Crops: ${stats.totalCrops}`);
|
||
console.log(` Applications: ${stats.totalApplications}`);
|
||
console.log(` Invoices: ${stats.totalInvoices}`);
|
||
|
||
if (stats.skipped.length > 0) {
|
||
console.log(`\nSkipped/Merged: ${stats.skipped.length} items`);
|
||
}
|
||
|
||
if (stats.errors.length > 0) {
|
||
console.log(`\nErrors: ${stats.errors.length}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse command line arguments
|
||
*/
|
||
function parseArguments() {
|
||
const args = process.argv.slice(2);
|
||
|
||
const options = {
|
||
preview: false,
|
||
sources: [],
|
||
destination: null,
|
||
convertToAdmin: true, // Default to true
|
||
updateInvoices: true,
|
||
reuseExistingEntities: false, // Default to false
|
||
outputFile: DEFAULT_OUTPUT_FILE,
|
||
help: false
|
||
};
|
||
|
||
for (let i = 0; i < args.length; i++) {
|
||
const arg = args[i];
|
||
|
||
switch (arg) {
|
||
case '--help':
|
||
case '-h':
|
||
options.help = true;
|
||
break;
|
||
case '--preview':
|
||
options.preview = true;
|
||
break;
|
||
case '--sources':
|
||
if (i + 1 < args.length) {
|
||
options.sources = args[++i].split(',').map(s => s.trim()).filter(s => s);
|
||
}
|
||
break;
|
||
case '--destination':
|
||
if (i + 1 < args.length) {
|
||
options.destination = args[++i].trim();
|
||
}
|
||
break;
|
||
case '--convert-to-admin':
|
||
options.convertToAdmin = true;
|
||
break;
|
||
case '--no-convert-to-admin':
|
||
case '--deactivate-source':
|
||
options.convertToAdmin = false;
|
||
break;
|
||
case '--skip-invoices':
|
||
options.updateInvoices = false;
|
||
break;
|
||
case '--reuse-existing-entities':
|
||
options.reuseExistingEntities = true;
|
||
break;
|
||
case '--output-file':
|
||
if (i + 1 < args.length) {
|
||
options.outputFile = args[++i].trim();
|
||
}
|
||
break;
|
||
case '--env':
|
||
// Environment file already processed at startup, just skip the value
|
||
if (i + 1 < args.length) {
|
||
i++; // Skip the env file path argument
|
||
}
|
||
break;
|
||
default:
|
||
console.error(`Unknown option: ${arg}`);
|
||
options.help = true;
|
||
}
|
||
}
|
||
|
||
return options;
|
||
}
|
||
|
||
/**
|
||
* Display help message
|
||
*/
|
||
function displayHelp() {
|
||
console.log(`
|
||
Customer Data Migration Script
|
||
==============================
|
||
|
||
Usage:
|
||
node scripts/migrateCustomerData.js [options]
|
||
|
||
Options:
|
||
--preview Show detailed migration plan without executing
|
||
--sources Comma-separated list of source customer IDs or usernames (required)
|
||
--destination Destination customer ID or username (required)
|
||
--convert-to-admin Convert source customers to admin (kind='2') - DEFAULT BEHAVIOR
|
||
--deactivate-source Deactivate source customers instead of converting to admin
|
||
--reuse-existing-entities Reuse destination's products/crops with matching names (avoids duplicates)
|
||
--skip-invoices Don't migrate invoice references
|
||
--output-file Custom output file path (default: ./migration_history.json)
|
||
--env Custom environment file path (default: ../environment.env)
|
||
--help, -h Show this help message
|
||
|
||
Examples:
|
||
|
||
# Preview migration using usernames (source becomes admin by default)
|
||
node scripts/migrateCustomerData.js \\
|
||
--preview \\
|
||
--sources acme_aviation,regional_ag \\
|
||
--destination consolidated_ag
|
||
|
||
# Execute migration (source customers converted to admin by default)
|
||
node scripts/migrateCustomerData.js \\
|
||
--sources acme_aviation \\
|
||
--destination consolidated_ag
|
||
|
||
# Execute migration and deactivate source instead of converting to admin
|
||
node scripts/migrateCustomerData.js \\
|
||
--sources acme_aviation \\
|
||
--destination consolidated_ag \\
|
||
--deactivate-source
|
||
|
||
# Preview migration using IDs
|
||
node scripts/migrateCustomerData.js \\
|
||
--preview \\
|
||
--sources 507f1f77bcf86cd799439011,507f1f77bcf86cd799439012 \\
|
||
--destination 507f191e810c19729de860ea
|
||
|
||
Note: Sub-accounts (clients, pilots, vehicles, officers, inspectors) keep their usernames
|
||
and simply get their parent reference updated to point to the destination customer.
|
||
Source customers are converted to admin accounts (kind='2') by default to preserve
|
||
all references in other collections (JobAssign, JobLog, etc.).
|
||
You can use either customer usernames or MongoDB ObjectIds (or mix them).
|
||
`);
|
||
}
|
||
|
||
// Command-line execution
|
||
if (require.main === module) {
|
||
const options = parseArguments();
|
||
|
||
if (options.help) {
|
||
displayHelp();
|
||
process.exit(0);
|
||
}
|
||
|
||
if (!options.sources || options.sources.length === 0) {
|
||
console.error('Error: --sources is required');
|
||
displayHelp();
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!options.destination) {
|
||
console.error('Error: --destination is required');
|
||
displayHelp();
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log('\n' + '='.repeat(80));
|
||
console.log('CUSTOMER DATA MIGRATION');
|
||
console.log('='.repeat(80) + '\n');
|
||
|
||
const dbConn = new DBConnection('Customer Data Migration');
|
||
|
||
dbConn.initialize({
|
||
setupExitHandlers: false,
|
||
onReady: async () => {
|
||
try {
|
||
await migrateCustomerData(options.sources, options.destination, options);
|
||
process.exit(0);
|
||
} catch (error) {
|
||
console.error('\n❌ Migration failed:', error.message);
|
||
debug('Full error:', error);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
module.exports = { migrateCustomerData };
|