Files
alla-allaos-fullstack/src/db/seeds/foundation.seed.ts
phaichayon 9c75788ba7 task-d.5
task-d.5.1
2026-06-24 12:28:04 +07:00

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;
});
}