const fs = require('node:fs'); const path = require('node:path'); const postgres = require('postgres'); type SqlClient = ReturnType; type SeedOption = { code: string; label: string; value: string; sortOrder: number; parentCode?: string; parentCategory?: string; }; type BranchSeedRow = { id: string; category: string; code: string; label: string; }; type TemplateColumnSeed = { columnName: string; sourceField: string; columnLetter?: string; sortOrder: number; formatMask?: string | null; }; type TemplateMappingSeed = { placeholderKey: string; sourcePath: string; dataType: 'scalar' | 'multiline' | 'table' | 'image'; defaultValue?: string | null; formatMask?: string | null; sortOrder: number; columns?: TemplateColumnSeed[]; }; type ReportDefinitionSeed = { code: string; name: string; description: string; category: string; }; function loadEnvFile(filePath: string) { if (!fs.existsSync(filePath)) { return; } const content = fs.readFileSync(filePath, 'utf8'); for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith('#')) { continue; } const separatorIndex = line.indexOf('='); if (separatorIndex === -1) { continue; } const key = line.slice(0, separatorIndex).trim(); let value = line.slice(separatorIndex + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } if (!(key in process.env)) { process.env[key] = value; } } } function loadLocalEnv() { loadEnvFile(path.resolve(process.cwd(), '.env.local')); loadEnvFile(path.resolve(process.cwd(), '.env')); } function requireEnv(name: string) { const value = process.env[name]?.trim(); if (!value) { throw new Error(`Missing required environment variable: ${name}`); } return value; } function getCurrentPeriod(date = new Date()) { const year = String(date.getFullYear()).slice(-2); const month = String(date.getMonth() + 1).padStart(2, '0'); return `${year}${month}`; } const FOUNDATION_OPTIONS = { crm_branch: [ { code: 'head_office', label: 'สำนักงานใหญ่', value: 'head_office', sortOrder: 1 }, { code: 'factory', label: 'Factory', value: 'factory', sortOrder: 2 }, { code: 'service', label: 'Service', value: 'service', sortOrder: 3 } ], crm_customer_status: [ { code: 'active', label: 'Active', value: 'active', sortOrder: 1 }, { code: 'inactive', label: 'Inactive', value: 'inactive', sortOrder: 2 }, { code: 'suspended', label: 'Suspended', value: 'suspended', sortOrder: 3 } ], crm_customer_type: [ { code: 'company', label: 'Company', value: 'company', sortOrder: 1 }, { code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 } ], crm_customer_group: [ { code: 'industrial', label: 'Industrial', value: 'industrial', sortOrder: 1 }, { code: 'construction', label: 'Construction', value: 'construction', sortOrder: 2 }, { code: 'government', label: 'Government', value: 'government', sortOrder: 3 }, { code: 'dealer', label: 'Dealer', value: 'dealer', sortOrder: 4 }, { code: 'other', label: 'Other', value: 'other', sortOrder: 5 } ], crm_customer_sub_group: [ { code: 'factory', label: 'Factory', value: 'factory', sortOrder: 1, parentCode: 'industrial', parentCategory: 'crm_customer_group' }, { code: 'warehouse', label: 'Warehouse', value: 'warehouse', sortOrder: 2, parentCode: 'industrial', parentCategory: 'crm_customer_group' }, { code: 'logistics', label: 'Logistics', value: 'logistics', sortOrder: 3, parentCode: 'industrial', parentCategory: 'crm_customer_group' }, { code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 4, parentCode: 'construction', parentCategory: 'crm_customer_group' }, { code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 5, parentCode: 'construction', parentCategory: 'crm_customer_group' }, { code: 'state_enterprise', label: 'State Enterprise', value: 'state_enterprise', sortOrder: 6, parentCode: 'government', parentCategory: 'crm_customer_group' }, { code: 'government_agency', label: 'Government Agency', value: 'government_agency', sortOrder: 7, parentCode: 'government', parentCategory: 'crm_customer_group' }, { code: 'reseller', label: 'Reseller', value: 'reseller', sortOrder: 8, parentCode: 'dealer', parentCategory: 'crm_customer_group' }, { code: 'other', label: 'Other', value: 'other', sortOrder: 9, parentCode: 'other', parentCategory: 'crm_customer_group' } ], crm_enquiry_status: [ { code: 'new', label: 'New', value: 'new', sortOrder: 1 }, { code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 }, { code: 'requirement', label: 'Requirement', value: 'requirement', sortOrder: 3 }, { code: 'follow_up', label: 'Follow Up', value: 'follow_up', sortOrder: 4 }, { code: 'converted', label: 'Converted', value: 'converted', sortOrder: 5 }, { code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 6 }, { code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 7 } ], crm_quotation_status: [ { code: 'draft', label: 'Draft', value: 'draft', sortOrder: 1 }, { code: 'pending_approval', label: 'Pending Approval', value: 'pending_approval', sortOrder: 2 }, { code: 'approved', label: 'Approved', value: 'approved', sortOrder: 3 }, { code: 'rejected', label: 'Rejected', value: 'rejected', sortOrder: 4 }, { code: 'sent', label: 'Sent', value: 'sent', sortOrder: 5 }, { code: 'accepted', label: 'Accepted', value: 'accepted', sortOrder: 6 }, { code: 'lost', label: 'Lost', value: 'lost', sortOrder: 7 }, { code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 8 }, { code: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 } ], crm_quotation_type: [ { code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 }, { code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 }, { code: 'solarcell', label: 'Solar Cell', value: 'solarcell', sortOrder: 3 }, { code: 'service', label: 'Service', value: 'service', sortOrder: 4 }, { code: 'other', label: 'Other', value: 'other', sortOrder: 5 } ], crm_product_type: [ { code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 }, { code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 }, { code: 'solarcell', label: 'Solar Cell', value: 'solarcell', sortOrder: 3 } ], crm_currency: [ { code: 'THB', label: 'THB', value: 'THB', sortOrder: 1 }, { code: 'USD', label: 'USD', value: 'USD', sortOrder: 2 }, { code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 } ], crm_discount_type: [ { code: 'fixed', label: 'Fixed', value: 'fixed', sortOrder: 1 }, { code: 'percentage', label: 'Percentage', value: 'percentage', sortOrder: 2 } ], crm_unit: [ { code: 'pcs', label: 'PCS', value: 'pcs', sortOrder: 1 }, { code: 'set', label: 'Set', value: 'set', sortOrder: 2 }, { code: 'lot', label: 'Lot', value: 'lot', sortOrder: 3 }, { code: 'job', label: 'Job', value: 'job', sortOrder: 4 } ], crm_sent_via: [ { code: 'email', label: 'Email', value: 'email', sortOrder: 1 }, { code: 'manual', label: 'Manual', value: 'manual', sortOrder: 2 }, { code: 'system', label: 'System', value: 'system', sortOrder: 3 } ], crm_project_party_role: [ { code: 'billing_customer', label: 'Billing Customer', value: 'billing_customer', sortOrder: 1 }, { code: 'customer', label: 'Customer', value: 'customer', sortOrder: 2 }, { code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 3 }, { code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 4 }, { code: 'end_customer', label: 'End Customer', value: 'end_customer', sortOrder: 5 }, { code: 'other', label: 'Other', value: 'other', sortOrder: 6 } ], crm_quotation_topic_type: [ { code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 }, { code: 'exclusion', label: 'Exclusion', value: 'exclusion', sortOrder: 2 }, { code: 'payment', label: 'Payment', value: 'payment', sortOrder: 3 } ], crm_payment_term: [ { code: 'cash', label: 'Cash', value: 'cash', sortOrder: 1 }, { code: 'credit_30', label: 'Credit 30', value: 'credit_30', sortOrder: 2 }, { code: 'credit_60', label: 'Credit 60', value: 'credit_60', sortOrder: 3 } ], crm_priority: [ { code: 'low', label: 'Low', value: 'low', sortOrder: 1 }, { code: 'normal', label: 'Normal', value: 'normal', sortOrder: 2 }, { code: 'high', label: 'High', value: 'high', sortOrder: 3 }, { code: 'urgent', label: 'Urgent', value: 'urgent', sortOrder: 4 } ], crm_followup_type: [ { code: 'call', label: 'Call', value: 'call', sortOrder: 1 }, { code: 'meeting', label: 'Meeting', value: 'meeting', sortOrder: 2 }, { code: 'site_visit', label: 'Site Visit', value: 'site_visit', sortOrder: 3 }, { code: 'email', label: 'Email', value: 'email', sortOrder: 4 }, { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 } ], crm_lost_reason: [ { code: 'competitor_won', label: 'Competitor Won', value: 'competitor_won', sortOrder: 1 }, { code: 'budget_not_approved', label: 'Budget Not Approved', value: 'budget_not_approved', sortOrder: 2 }, { code: 'project_cancelled', label: 'Project Cancelled', value: 'project_cancelled', sortOrder: 3 }, { code: 'customer_no_response', label: 'Customer No Response', value: 'customer_no_response', sortOrder: 4 }, { code: 'technical_requirement_mismatch', label: 'Technical Requirement Mismatch', value: 'technical_requirement_mismatch', sortOrder: 5 }, { code: 'price_too_high', label: 'Price Too High', value: 'price_too_high', sortOrder: 6 }, { code: 'timeline_not_fit', label: 'Timeline Not Fit', value: 'timeline_not_fit', sortOrder: 7 }, { code: 'other', label: 'Other', value: 'other', sortOrder: 8 } ], crm_job_title: [ { code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 }, { code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 }, { code: 'sales_manager', label: 'Sales Manager', value: 'sales_manager', sortOrder: 3 }, { code: 'department_manager', label: 'Department Manager', value: 'department_manager', sortOrder: 4 }, { code: 'top_manager', label: 'General Manager', value: 'top_manager', sortOrder: 5 } ], crm_lead_channel: [ { code: 'website', label: 'Website', value: 'website', sortOrder: 1 }, { code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 }, { code: 'sales', label: 'Sales', value: 'sales', sortOrder: 3 }, { code: 'facebook', label: 'Facebook', value: 'facebook', sortOrder: 4 }, { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }, { code: 'other', label: 'Other', value: 'other', sortOrder: 6 } ], crm_lead_awareness: [ { code: 'google_search_website', label: 'Google Search / Website', value: 'google_search_website', sortOrder: 1 }, { code: 'social_media', label: 'Social Media', value: 'social_media', sortOrder: 2 }, { code: 'email_marketing', label: 'Email Marketing', value: 'email_marketing', sortOrder: 3 }, { code: 'visit', label: 'Visit', value: 'visit', sortOrder: 4 }, { code: 'exhibition_event', label: 'Exhibition / Event', value: 'exhibition_event', sortOrder: 5 }, { code: 'brochure_catalog', label: 'Brochure / Catalog', value: 'brochure_catalog', sortOrder: 6 }, { code: 'word_of_mouth', label: 'Word of Mouth', value: 'word_of_mouth', sortOrder: 7 }, { code: 'corporate_car', label: 'Corporate Car', value: 'corporate_car', sortOrder: 8 }, { code: 'product_label', label: 'Product Label', value: 'product_label', sortOrder: 9 }, { code: 'repurchasing', label: 'Re-purchasing', value: 'repurchasing', sortOrder: 10 }, { code: 'introducing', label: 'Introducing', value: 'introducing', sortOrder: 11 }, { code: 'bci', label: 'BCI', value: 'bci', sortOrder: 12 } ], crm_lead_status: [ { code: 'new_job', label: 'New Job', value: 'new_job', sortOrder: 1 }, { code: 'follow_up', label: 'Follow Up', value: 'follow_up', sortOrder: 2 }, { code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 3 }, { code: 'cancel', label: 'Cancel', value: 'cancel', sortOrder: 4 } ], crm_lead_followup_status: [ { code: 'budget_pending', label: 'Budget Pending', value: 'budget_pending', sortOrder: 1 }, { code: 'project_under_review', label: 'Project Under Review', value: 'project_under_review', sortOrder: 2 }, { code: 'spec_clarification', label: 'Spec Clarification', value: 'spec_clarification', sortOrder: 3 }, { code: 'site_survey_waiting', label: 'Site Survey Waiting', value: 'site_survey_waiting', sortOrder: 4 }, { code: 'drawing_followup', label: 'Drawing Follow-up', value: 'drawing_followup', sortOrder: 5 }, { code: 'quotation_preparation', label: 'Quotation Preparation', value: 'quotation_preparation', sortOrder: 6 }, { code: 'waiting_po', label: 'Waiting PO', value: 'waiting_po', sortOrder: 7 } ], crm_lead_lost_reason: [ { code: 'price', label: 'Price', value: 'price', sortOrder: 1 }, { code: 'delivery_time', label: 'Delivery Time', value: 'delivery_time', sortOrder: 2 }, { code: 'sales_person', label: 'Sales Person', value: 'sales_person', sortOrder: 3 }, { code: 'service', label: 'Service', value: 'service', sortOrder: 4 }, { code: 'customer', label: 'Customer', value: 'customer', sortOrder: 5 }, { code: 'product_quality', label: 'Product Quality', value: 'product_quality', sortOrder: 6 }, { code: 'other', label: 'Other', value: 'other', sortOrder: 7 }, { code: 'transfer', label: 'Transfer', value: 'transfer', sortOrder: 8 } ], crm_report_category: [ { code: 'pipeline', label: 'Pipeline', value: 'pipeline', sortOrder: 1 }, { code: 'sales', label: 'Sales', value: 'sales', sortOrder: 2 }, { code: 'revenue', label: 'Revenue', value: 'revenue', sortOrder: 3 }, { code: 'customer', label: 'Customer', value: 'customer', sortOrder: 4 }, { code: 'quotation', label: 'Quotation', value: 'quotation', sortOrder: 5 }, { code: 'approval', label: 'Approval', value: 'approval', sortOrder: 6 }, { code: 'outcome', label: 'Outcome', value: 'outcome', sortOrder: 7 }, { code: 'executive', label: 'Executive', value: 'executive', sortOrder: 8 } ] }; const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [ { code: 'lead_pipeline', name: 'Lead Pipeline', description: 'Operational report for marketing lead intake, assignment, and conversion status.', category: 'pipeline' }, { code: 'lead_aging', name: 'Lead Aging', description: 'Operational report for stagnant lead monitoring and follow-up prioritization.', category: 'pipeline' }, { code: 'enquiry_pipeline', name: 'Enquiry Pipeline', description: 'Operational report for open enquiries, quotation progress, and sales pipeline health.', category: 'pipeline' }, { code: 'enquiry_aging', name: 'Enquiry Aging', description: 'Operational report for stalled enquiries and aging opportunity follow-ups.', category: 'pipeline' }, { code: 'pipeline_funnel', name: 'Pipeline Funnel', description: 'Lifecycle funnel from lead through quotation to won and lost outcomes.', category: 'pipeline' }, { code: 'lead_conversion', name: 'Lead Conversion', description: 'Marketing conversion report from lead creation into active enquiries.', category: 'pipeline' }, { code: 'enquiry_conversion', name: 'Enquiry Conversion', description: 'Sales conversion report from enquiry creation into quotation generation.', category: 'pipeline' }, { code: 'sales_performance', name: 'Sales Performance', description: 'Foundation catalog entry for salesperson KPI and ranking reports.', category: 'sales' }, { code: 'lost_analysis', name: 'Lost Analysis', description: 'Foundation catalog entry for lost reason and competitor analysis.', category: 'outcome' }, { code: 'revenue_by_customer', name: 'Revenue By Customer', description: 'Foundation catalog entry for customer revenue attribution reports.', category: 'revenue' } ]; const DOCUMENT_SEQUENCES = [ { documentType: 'customer', prefix: 'CUS' }, { documentType: 'contact', prefix: 'CON' }, { documentType: 'enquiry', prefix: 'ENQ' }, { documentType: 'crm_lead', prefix: 'LD' }, { documentType: 'crm_enquiry', prefix: 'EN' }, { documentType: 'quotation', prefix: 'QT' }, { documentType: 'approval', prefix: 'APV' } ]; const APPROVAL_WORKFLOWS = [ { code: 'quotation_standard_approval', name: 'Quotation Standard Approval', entityType: 'quotation', steps: [ { stepNumber: 1, roleCode: 'sales_manager', roleName: 'Sales Manager' }, { stepNumber: 2, roleCode: 'department_manager', roleName: 'Department Manager' }, { stepNumber: 3, roleCode: 'top_manager', roleName: 'Top Manager' } ] } ]; const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [ { placeholderKey: 'customer_name', sourcePath: 'customer.name', dataType: 'scalar', sortOrder: 1 }, { placeholderKey: 'customer_addr', sourcePath: 'customer.address', dataType: 'multiline', sortOrder: 2 }, { placeholderKey: 'customer_tel', sourcePath: 'customer.phone', dataType: 'scalar', sortOrder: 3 }, { placeholderKey: 'customer_email', sourcePath: 'customer.email', dataType: 'scalar', sortOrder: 4 }, { placeholderKey: 'customer_att', sourcePath: 'quotation.attention', dataType: 'scalar', sortOrder: 5 }, { placeholderKey: 'project_name', sourcePath: 'quotation.projectName', dataType: 'scalar', sortOrder: 6 }, { placeholderKey: 'site_location', sourcePath: 'quotation.projectLocation', dataType: 'multiline', sortOrder: 7 }, { placeholderKey: 'quotation_date', sourcePath: 'pdfme.quotation_date', dataType: 'scalar', formatMask: null, sortOrder: 8 }, { placeholderKey: 'quotation_code', sourcePath: 'quotation.code', dataType: 'scalar', sortOrder: 9 }, { placeholderKey: 'quotation_price_data', sourcePath: 'pdfme.quotation_price_data', dataType: 'table', defaultValue: JSON.stringify([['Price (Exclude VAT)', '-', ' ', 'THB']]), sortOrder: 10 }, { placeholderKey: 'quotation_price', sourcePath: 'pdfme.quotation_price', dataType: 'scalar', formatMask: null, sortOrder: 11 }, { placeholderKey: 'currency', sourcePath: 'quotation.currency', dataType: 'scalar', sortOrder: 12 }, { placeholderKey: 'exclusion_data', sourcePath: 'pdfme.exclusion_data', dataType: 'table', sortOrder: 13 }, { placeholderKey: 'app1', sourcePath: 'signatures.preparedBy.name', dataType: 'scalar', defaultValue: '-', sortOrder: 14 }, { placeholderKey: 'app1_position', sourcePath: 'signatures.preparedBy.position', dataType: 'scalar', defaultValue: '-', sortOrder: 15 }, { placeholderKey: 'app2', sourcePath: 'signatures.approvedBy.name', dataType: 'scalar', defaultValue: '-', sortOrder: 16 }, { placeholderKey: 'app2_position', sourcePath: 'signatures.approvedBy.position', dataType: 'scalar', defaultValue: '-', sortOrder: 17 }, { placeholderKey: 'app3', sourcePath: 'signatures.authorizedBy.name', dataType: 'scalar', defaultValue: '-', sortOrder: 18 }, { placeholderKey: 'app3_position', sourcePath: 'signatures.authorizedBy.position', dataType: 'scalar', defaultValue: '-', sortOrder: 19 }, { placeholderKey: 'items_table', sourcePath: 'items', dataType: 'table', sortOrder: 20, columns: [ { columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 }, { columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 }, { columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 }, { columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 }, { columnName: 'Unit Price', sourceField: 'unitPrice', columnLetter: 'E', sortOrder: 5, formatMask: 'currency' }, { columnName: 'Total', sourceField: 'totalPrice', columnLetter: 'F', sortOrder: 6, formatMask: 'currency' } ] } ]; function loadTemplateSchemaByOrganizationName(organizationName: string) { const normalized = organizationName.toUpperCase(); const fileName = normalized.includes('ONVALLA') ? 'ONVALLA_template_pdfme_fainal3.json' : normalized.includes('ALLA') ? 'ALLA_template_pdfme_fainal3.json' : null; if (!fileName) { return { filePath: null, templateName: `${organizationName} Quotation Standard`, schemaJson: { basePdf: '', schemas: [], pdfmeVersion: 'fallback' } }; } const absolutePath = path.resolve(process.cwd(), 'src', 'pdfme_template', fileName); const raw = fs.readFileSync(absolutePath, 'utf8'); return { filePath: `src/pdfme_template/${fileName}`, templateName: `${organizationName} Quotation Standard`, schemaJson: JSON.parse(raw) }; } async function upsertMasterOptionsForOrganization( sql: SqlClient, organizationId: string ): Promise { const branchRows: BranchSeedRow[] = []; for (const [category, options] of Object.entries(FOUNDATION_OPTIONS) as Array< [string, SeedOption[]] >) { for (const option of options) { const parentRow = option.parentCode && option.parentCategory ? (( await sql` select id from ms_options where organization_id = ${organizationId} and category = ${option.parentCategory} and code = ${option.parentCode} and deleted_at is null limit 1 ` )[0] ?? null) : null; const [row] = await sql` insert into ms_options ( id, organization_id, category, code, label, value, parent_id, sort_order, is_active, metadata, deleted_at ) values ( ${crypto.randomUUID()}, ${organizationId}, ${category}, ${option.code}, ${option.label}, ${option.value}, ${parentRow?.id ?? null}, ${option.sortOrder}, ${true}, ${null}, ${null} ) on conflict (organization_id, category, code) do update set label = excluded.label, value = excluded.value, parent_id = excluded.parent_id, sort_order = excluded.sort_order, is_active = excluded.is_active, metadata = excluded.metadata, deleted_at = excluded.deleted_at, updated_at = now() returning id, category, code, label `; if (category === 'crm_branch') { branchRows.push(row); } } } return branchRows; } async function upsertDocumentSequencesForOrganization( sql: SqlClient, organizationId: string, branchRows: BranchSeedRow[] ) { if (!branchRows.length) { throw new Error( `No crm_branch options were found for organization ${organizationId}. Foundation branch seed must run first.` ); } const period = getCurrentPeriod(); for (const branch of branchRows) { for (const sequence of DOCUMENT_SEQUENCES) { await sql` insert into document_sequences ( id, organization_id, branch_id, document_type, prefix, period, current_number, padding_length, is_active ) values ( ${crypto.randomUUID()}, ${organizationId}, ${branch.id}, ${sequence.documentType}, ${sequence.prefix}, ${period}, ${0}, ${3}, ${true} ) on conflict (organization_id, document_type, period, branch_id) do update set prefix = excluded.prefix, padding_length = excluded.padding_length, is_active = excluded.is_active, updated_at = now() `; } } } async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizationId: string) { for (const workflow of APPROVAL_WORKFLOWS) { const [workflowRow] = await sql` insert into crm_approval_workflows ( id, organization_id, code, name, entity_type, is_active, deleted_at ) values ( ${crypto.randomUUID()}, ${organizationId}, ${workflow.code}, ${workflow.name}, ${workflow.entityType}, ${true}, ${null} ) on conflict (organization_id, code) do update set name = excluded.name, entity_type = excluded.entity_type, is_active = excluded.is_active, deleted_at = excluded.deleted_at, updated_at = now() returning id `; for (const step of workflow.steps) { await sql` insert into crm_approval_steps ( id, organization_id, workflow_id, step_number, role_code, role_name, is_required, deleted_at ) values ( ${crypto.randomUUID()}, ${organizationId}, ${workflowRow.id}, ${step.stepNumber}, ${step.roleCode}, ${step.roleName}, ${true}, ${null} ) on conflict (workflow_id, step_number) do update set role_code = excluded.role_code, role_name = excluded.role_name, is_required = excluded.is_required, deleted_at = excluded.deleted_at, updated_at = now() `; } } } async function upsertDocumentTemplatesForOrganization( sql: SqlClient, organization: { id: string; name: string; createdBy: string } ) { const templateSeed = loadTemplateSchemaByOrganizationName(organization.name); const [templateRow] = await sql` insert into crm_document_templates ( id, organization_id, document_type, product_type, file_type, template_name, description, is_default, is_active, deleted_at, created_by, updated_by ) values ( ${crypto.randomUUID()}, ${organization.id}, ${'quotation'}, ${'default'}, ${'pdfme'}, ${templateSeed.templateName}, ${'Seeded quotation template metadata for production preview foundation.'}, ${true}, ${true}, ${null}, ${organization.createdBy}, ${organization.createdBy} ) on conflict do nothing returning id `; const resolvedTemplateId = templateRow?.id ?? ( await sql` select id from crm_document_templates where organization_id = ${organization.id} and document_type = ${'quotation'} and product_type = ${'default'} and file_type = ${'pdfme'} and deleted_at is null order by is_default desc, created_at asc limit 1 ` )[0]?.id; if (!resolvedTemplateId) { throw new Error(`Unable to resolve template id for organization ${organization.id}`); } const [versionRow] = await sql` insert into crm_document_template_versions ( id, organization_id, template_id, version, file_path, schema_json, preview_image_url, is_active, deleted_at, created_by ) values ( ${crypto.randomUUID()}, ${organization.id}, ${resolvedTemplateId}, ${'1.0'}, ${templateSeed.filePath}, ${JSON.stringify(templateSeed.schemaJson)}, ${null}, ${true}, ${null}, ${organization.createdBy} ) on conflict do nothing returning id `; const resolvedVersionId = versionRow?.id ?? ( await sql` select id from crm_document_template_versions where organization_id = ${organization.id} and template_id = ${resolvedTemplateId} and version = ${'1.0'} and deleted_at is null order by created_at asc limit 1 ` )[0]?.id; if (!resolvedVersionId) { throw new Error(`Unable to resolve template version id for organization ${organization.id}`); } for (const mapping of DOCUMENT_TEMPLATE_MAPPINGS) { const existingMapping = ( await sql` select id from crm_document_template_mappings where organization_id = ${organization.id} and template_version_id = ${resolvedVersionId} and placeholder_key = ${mapping.placeholderKey} limit 1 ` )[0] ?? null; const resolvedMappingId = existingMapping?.id ?? crypto.randomUUID(); if (existingMapping) { await sql` update crm_document_template_mappings set source_path = ${mapping.sourcePath}, data_type = ${mapping.dataType}, sheet_name = ${null}, default_value = ${mapping.defaultValue ?? null}, format_mask = ${mapping.formatMask ?? null}, sort_order = ${mapping.sortOrder}, deleted_at = ${null}, updated_at = now() where id = ${resolvedMappingId} `; } else { await sql` insert into crm_document_template_mappings ( id, organization_id, template_version_id, placeholder_key, source_path, data_type, sheet_name, default_value, format_mask, sort_order, deleted_at ) values ( ${resolvedMappingId}, ${organization.id}, ${resolvedVersionId}, ${mapping.placeholderKey}, ${mapping.sourcePath}, ${mapping.dataType}, ${null}, ${mapping.defaultValue ?? null}, ${mapping.formatMask ?? null}, ${mapping.sortOrder}, ${null} ) `; } if (!resolvedMappingId || !mapping.columns?.length) { continue; } for (const column of mapping.columns) { const existingColumn = ( await sql` select id from crm_document_template_table_columns where organization_id = ${organization.id} and mapping_id = ${resolvedMappingId} and column_name = ${column.columnName} limit 1 ` )[0] ?? null; if (existingColumn) { await sql` update crm_document_template_table_columns set source_field = ${column.sourceField}, column_letter = ${column.columnLetter ?? null}, sort_order = ${column.sortOrder}, format_mask = ${column.formatMask ?? null}, deleted_at = ${null}, updated_at = now() where id = ${existingColumn.id} `; continue; } await sql` insert into crm_document_template_table_columns ( id, organization_id, mapping_id, column_name, source_field, column_letter, sort_order, format_mask, deleted_at ) values ( ${crypto.randomUUID()}, ${organization.id}, ${resolvedMappingId}, ${column.columnName}, ${column.sourceField}, ${column.columnLetter ?? null}, ${column.sortOrder}, ${column.formatMask ?? null}, ${null} ) `; } } await sql` update crm_document_template_mappings set deleted_at = now(), updated_at = now() where organization_id = ${organization.id} and template_version_id = ${resolvedVersionId} and ( placeholder_key in (${'topic'}, ${'data_topic'}, ${'item_topic'}) or placeholder_key like 'topic\_%' escape '\' or placeholder_key like 'data_topic\_%' escape '\' or placeholder_key like 'item_topic\_%' escape '\' ) and deleted_at is null `; await sql` update crm_document_template_mappings set deleted_at = now(), updated_at = now() where organization_id = ${organization.id} and template_version_id = ${resolvedVersionId} and placeholder_key in (${'exclusion_data_'}) and deleted_at is null `; } async function upsertReportDefinitionsForOrganization( sql: SqlClient, organization: { id: string } ) { for (const definition of REPORT_DEFINITIONS) { await sql` insert into crm_report_definitions ( id, organization_id, code, name, description, category, is_system, is_active ) values ( ${crypto.randomUUID()}, ${organization.id}, ${definition.code}, ${definition.name}, ${definition.description}, ${definition.category}, ${true}, ${true} ) on conflict (organization_id, code) do update set name = excluded.name, description = excluded.description, category = excluded.category, is_system = excluded.is_system, is_active = excluded.is_active, updated_at = now() `; } } async function main() { loadLocalEnv(); const databaseUrl = requireEnv('DATABASE_URL'); const sql = postgres(databaseUrl, { prepare: false }); try { const organizations = await sql` select id, name, created_by as "createdBy" from organizations order by name asc `; if (organizations.length === 0) { throw new Error( 'No organizations found. Create at least one organization before running foundation seed.' ); } for (const organization of organizations) { const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id); await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows); await upsertApprovalWorkflowsForOrganization(sql, organization.id); await upsertDocumentTemplatesForOrganization(sql, organization); await upsertReportDefinitionsForOrganization(sql, organization); console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); } } finally { await sql.end(); } } if (require.main === module) { main().catch((error) => { console.error('[seed-foundation] Failed:', error.message); process.exitCode = 1; }); }