1231 lines
35 KiB
TypeScript
1231 lines
35 KiB
TypeScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const postgres = require('postgres');
|
|
|
|
type SqlClient = ReturnType<typeof postgres>;
|
|
|
|
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<BranchSeedRow[]> {
|
|
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;
|
|
});
|
|
}
|