const fs = require('node:fs'); const path = require('node:path'); const { hash } = require('bcryptjs'); const postgres = require('postgres'); const TASK_H1_PASSWORD = 'TaskH1Pass!2026'; const FIXTURE_CODES = { customer: 'CUS-TASK-H1', approvedQuotation: 'QT-TASK-H1-APPROVED', draftQuotation: 'QT-TASK-H1-DRAFT', auditQuotation: 'QT-H5-AUDIT', billingCustomer: 'CUS-TASK-H5-BILL', consultantCustomer: 'CUS-TASK-H5-CONSULT' }; const FIXTURE_USERS = { admin: 'task-h1-admin@local.test', pdfUser: 'task-h1-pdf-user@local.test', viewer: 'task-h1-viewer@local.test' }; const PDF_PERMISSIONS = [ 'crm.quotation.read', 'crm.quotation.document.preview', 'crm.quotation.pdf.preview', 'crm.quotation.pdf.download', 'crm.quotation.pdf.generate_approved', 'crm.document_artifact.read', 'crm.document_artifact.download' ]; function loadEnvFile(filePath) { 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) { const value = process.env[name]?.trim(); if (!value) { throw new Error(`Missing required environment variable: ${name}`); } return value; } async function resolveOptionId(sql, organizationId, category, code) { const [row] = await sql` select id from ms_options where organization_id = ${organizationId} and category = ${category} and code = ${code} and is_active = true and deleted_at is null limit 1 `; if (!row) { throw new Error(`Missing option ${category}:${code}`); } return row.id; } async function resolveApprovalWorkflow(sql, organizationId) { const [workflow] = await sql` select id from crm_approval_workflows where organization_id = ${organizationId} and code = 'quotation_standard_approval' and is_active = true and deleted_at is null limit 1 `; if (!workflow) { throw new Error('Missing quotation_standard_approval workflow'); } const steps = await sql` select step_number as "stepNumber", role_code as "roleCode", role_name as "roleName" from crm_approval_steps where organization_id = ${organizationId} and workflow_id = ${workflow.id} and deleted_at is null order by step_number asc `; if (!steps.length) { throw new Error('Approval workflow has no steps'); } return { id: workflow.id, steps }; } async function ensureUser(sql, organization, creatorId, email, name, membership) { const passwordHash = await hash(TASK_H1_PASSWORD, 10); const existing = ( await sql` select id from users where email = ${email} limit 1 ` )[0] ?? null; const userId = existing?.id ?? crypto.randomUUID(); if (existing) { await sql` update users set name = ${name}, password_hash = ${passwordHash}, system_role = 'user', active_organization_id = ${organization.id}, updated_at = now() where id = ${userId} `; } else { await sql` insert into users ( id, name, email, password_hash, system_role, active_organization_id ) values ( ${userId}, ${name}, ${email}, ${passwordHash}, 'user', ${organization.id} ) `; } const membershipRow = ( await sql` select id from memberships where user_id = ${userId} and organization_id = ${organization.id} limit 1 ` )[0] ?? null; if (membershipRow) { await sql` update memberships set role = ${membership.role}, business_role = ${membership.businessRole}, permissions = ${membership.permissions}, updated_at = now() where id = ${membershipRow.id} `; } else { await sql` insert into memberships ( id, user_id, organization_id, role, business_role, permissions ) values ( ${crypto.randomUUID()}, ${userId}, ${organization.id}, ${membership.role}, ${membership.businessRole}, ${membership.permissions} ) `; } return { id: userId, email }; } async function ensureCustomerAndContact(sql, organization, creatorId, optionIds) { const existingCustomer = ( await sql` select id from crm_customers where organization_id = ${organization.id} and code = ${FIXTURE_CODES.customer} limit 1 ` )[0] ?? null; const customerId = existingCustomer?.id ?? crypto.randomUUID(); if (existingCustomer) { await sql` update crm_customers set branch_id = ${optionIds.branchId}, name = 'Task H.1 Demo Customer', abbr = 'TASKH1', tax_id = '0105559001122', customer_type = ${optionIds.customerTypeCompanyId}, customer_status = ${optionIds.customerStatusActiveId}, address = '99/9 Rama IX Road, Bangkok 10310', phone = '02-000-1000', email = 'demo-customer@local.test', lead_channel = ${optionIds.leadChannelSalesId}, customer_group = ${optionIds.customerGroupStandardId}, notes = 'Task H.1 smoke-test customer', is_active = true, deleted_at = null, updated_at = now(), updated_by = ${creatorId} where id = ${customerId} `; } else { await sql` insert into crm_customers ( id, organization_id, branch_id, code, name, abbr, tax_id, customer_type, customer_status, address, phone, email, lead_channel, customer_group, notes, is_active, created_by, updated_by ) values ( ${customerId}, ${organization.id}, ${optionIds.branchId}, ${FIXTURE_CODES.customer}, 'Task H.1 Demo Customer', 'TASKH1', '0105559001122', ${optionIds.customerTypeCompanyId}, ${optionIds.customerStatusActiveId}, '99/9 Rama IX Road, Bangkok 10310', '02-000-1000', 'demo-customer@local.test', ${optionIds.leadChannelSalesId}, ${optionIds.customerGroupStandardId}, 'Task H.1 smoke-test customer', true, ${creatorId}, ${creatorId} ) `; } const existingContact = ( await sql` select id from crm_customer_contacts where organization_id = ${organization.id} and customer_id = ${customerId} and email = 'demo-contact@local.test' limit 1 ` )[0] ?? null; const contactId = existingContact?.id ?? crypto.randomUUID(); if (existingContact) { await sql` update crm_customer_contacts set name = 'Task H.1 Contact', position = 'Procurement Manager', department = 'Procurement', phone = '02-000-1001', mobile = '081-000-1001', email = 'demo-contact@local.test', is_primary = true, notes = 'Primary smoke-test contact', is_active = true, deleted_at = null, updated_at = now(), updated_by = ${creatorId} where id = ${contactId} `; } else { await sql` insert into crm_customer_contacts ( id, organization_id, customer_id, name, position, department, phone, mobile, email, is_primary, notes, is_active, created_by, updated_by ) values ( ${contactId}, ${organization.id}, ${customerId}, 'Task H.1 Contact', 'Procurement Manager', 'Procurement', '02-000-1001', '081-000-1001', 'demo-contact@local.test', true, 'Primary smoke-test contact', true, ${creatorId}, ${creatorId} ) `; } return { customerId, contactId }; } async function ensureRelatedCustomer(sql, organization, creatorId, optionIds, definition) { const existing = ( await sql` select id from crm_customers where organization_id = ${organization.id} and code = ${definition.code} limit 1 ` )[0] ?? null; const customerId = existing?.id ?? crypto.randomUUID(); if (existing) { await sql` update crm_customers set branch_id = ${optionIds.branchId}, name = ${definition.name}, abbr = ${definition.abbr}, tax_id = ${definition.taxId}, customer_type = ${optionIds.customerTypeCompanyId}, customer_status = ${optionIds.customerStatusActiveId}, address = ${definition.address}, phone = ${definition.phone}, email = ${definition.email}, lead_channel = ${optionIds.leadChannelSalesId}, customer_group = ${optionIds.customerGroupStandardId}, notes = ${definition.notes}, is_active = true, deleted_at = null, updated_at = now(), updated_by = ${creatorId} where id = ${customerId} `; } else { await sql` insert into crm_customers ( id, organization_id, branch_id, code, name, abbr, tax_id, customer_type, customer_status, address, phone, email, lead_channel, customer_group, notes, is_active, created_by, updated_by ) values ( ${customerId}, ${organization.id}, ${optionIds.branchId}, ${definition.code}, ${definition.name}, ${definition.abbr}, ${definition.taxId}, ${optionIds.customerTypeCompanyId}, ${optionIds.customerStatusActiveId}, ${definition.address}, ${definition.phone}, ${definition.email}, ${optionIds.leadChannelSalesId}, ${optionIds.customerGroupStandardId}, ${definition.notes}, true, ${creatorId}, ${creatorId} ) `; } return customerId; } async function upsertQuotation(sql, args) { const { organization, creatorId, workflow, optionIds, customerId, contactId, code, approved, fixtureProfile = 'h1', projectParties = [] } = args; const existing = ( await sql` select id from crm_quotations where organization_id = ${organization.id} and code = ${code} limit 1 ` )[0] ?? null; const quotationId = existing?.id ?? crypto.randomUUID(); const statusId = approved ? optionIds.quotationStatusApprovedId : optionIds.quotationStatusDraftId; const approvalNow = approved ? new Date().toISOString() : null; const notes = fixtureProfile === 'h5' ? 'Approved quotation fixture for Task H.5 PDF mapping and parity audit.' : approved ? 'Approved quotation fixture for Task H.1 PDF stabilization.' : 'Draft quotation fixture for Task H.1 negative API validation.'; const projectName = fixtureProfile === 'h5' ? 'Task H.5 Audit Fixture' : approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'; const reference = fixtureProfile === 'h5' ? 'TASK-H5-AUDIT' : approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'; if (existing) { await sql` update crm_quotations set branch_id = ${optionIds.branchId}, enquiry_id = null, customer_id = ${customerId}, contact_id = ${contactId}, quotation_date = ${'2026-06-16T00:00:00.000Z'}, valid_until = ${'2026-07-16T00:00:00.000Z'}, quotation_type = ${optionIds.quotationTypeServiceId}, project_name = ${projectName}, project_location = 'Bangkok HQ', attention = 'Task H.1 Contact', reference = ${reference}, notes = ${notes}, status = ${statusId}, revision = 0, parent_quotation_id = null, revision_remark = null, currency = ${optionIds.currencyThbId}, exchange_rate = 1, subtotal = 125000, discount = 5000, discount_type = ${optionIds.discountPercentageId}, tax_rate = 7, tax_amount = 8400, total_amount = 128400, chance_percent = 90, is_hot_project = true, competitor = 'Task H.1 Competitor', salesman_id = ${creatorId}, is_sent = ${approved}, sent_at = ${approved ? '2026-06-16T01:00:00.000Z' : null}, sent_via = ${optionIds.sentViaEmailId}, approved_at = ${approvalNow}, approved_pdf_url = null, approved_snapshot = null, approved_template_version_id = null, accepted_at = null, rejected_at = null, rejection_reason = null, is_active = true, deleted_at = null, updated_at = now(), updated_by = ${creatorId} where id = ${quotationId} `; } else { await sql` insert into crm_quotations ( id, organization_id, branch_id, code, enquiry_id, customer_id, contact_id, quotation_date, valid_until, quotation_type, project_name, project_location, attention, reference, notes, status, revision, parent_quotation_id, revision_remark, currency, exchange_rate, subtotal, discount, discount_type, tax_rate, tax_amount, total_amount, chance_percent, is_hot_project, competitor, salesman_id, is_sent, sent_at, sent_via, approved_at, approved_pdf_url, approved_snapshot, approved_template_version_id, accepted_at, rejected_at, rejection_reason, is_active, created_by, updated_by ) values ( ${quotationId}, ${organization.id}, ${optionIds.branchId}, ${code}, null, ${customerId}, ${contactId}, ${'2026-06-16T00:00:00.000Z'}, ${'2026-07-16T00:00:00.000Z'}, ${optionIds.quotationTypeServiceId}, ${projectName}, 'Bangkok HQ', 'Task H.1 Contact', ${reference}, ${notes}, ${statusId}, 0, null, null, ${optionIds.currencyThbId}, 1, 125000, 5000, ${optionIds.discountPercentageId}, 7, 8400, 128400, 90, true, 'Task H.1 Competitor', ${creatorId}, ${approved}, ${approved ? '2026-06-16T01:00:00.000Z' : null}, ${optionIds.sentViaEmailId}, ${approvalNow}, null, null, null, null, null, null, true, ${creatorId}, ${creatorId} ) `; } await sql`delete from crm_quotation_attachments where quotation_id = ${quotationId}`; await sql` delete from crm_approval_actions where approval_request_id in ( select id from crm_approval_requests where entity_type = 'quotation' and entity_id = ${quotationId} ) `; await sql` delete from crm_approval_requests where entity_type = 'quotation' and entity_id = ${quotationId} `; await sql`delete from crm_quotation_customers where quotation_id = ${quotationId}`; await sql` delete from crm_quotation_topic_items where topic_id in ( select id from crm_quotation_topics where quotation_id = ${quotationId} ) `; await sql`delete from crm_quotation_topics where quotation_id = ${quotationId}`; await sql`delete from crm_quotation_items where quotation_id = ${quotationId}`; const items = fixtureProfile === 'h5' ? [ { itemNumber: 1, description: '1 set overhead crane with installation and commissioning', quantity: 1, unitPrice: 125000, discount: 5000, totalPrice: 120000, notes: 'Primary lifting package' }, { itemNumber: 2, description: 'Load testing and safety certification', quantity: 1, unitPrice: 25000, discount: 0, totalPrice: 25000, notes: 'Audit fixture support line' }, { itemNumber: 3, description: 'Operator training and turnover documentation', quantity: 2, unitPrice: 8000, discount: 0, totalPrice: 16000, notes: 'Audit fixture service line' } ] : [ { itemNumber: 1, description: '1 set overhead crane with installation and commissioning', quantity: 1, unitPrice: 125000, discount: 5000, totalPrice: 120000, notes: 'Smoke-test commercial line' } ]; for (const [index, item] of items.entries()) { await sql` insert into crm_quotation_items ( id, organization_id, quotation_id, item_number, product_type, description, quantity, unit, unit_price, discount, discount_type, tax_rate, total_price, notes, sort_order ) values ( ${crypto.randomUUID()}, ${organization.id}, ${quotationId}, ${item.itemNumber}, ${optionIds.productTypeCraneId}, ${item.description}, ${item.quantity}, ${optionIds.unitSetId}, ${item.unitPrice}, ${item.discount}, ${optionIds.discountPercentageId}, 7, ${item.totalPrice}, ${item.notes}, ${index + 1} ) `; } const resolvedProjectParties = projectParties.length > 0 ? projectParties : [ { customerId, role: optionIds.customerRoleCustomerId, isPrimary: true } ]; for (const [index, party] of resolvedProjectParties.entries()) { await sql` insert into crm_quotation_customers ( id, organization_id, quotation_id, customer_id, role, is_primary, remark ) values ( ${crypto.randomUUID()}, ${organization.id}, ${quotationId}, ${party.customerId}, ${party.role}, ${party.isPrimary}, ${party.remark ?? `Fixture party ${index + 1}`} ) `; } const topics = fixtureProfile === 'h5' ? [ { typeId: optionIds.topicTypeScopeId, title: 'Scope', items: [ 'Supply crane equipment', 'Install and test the system', 'Perform SAT with project stakeholders' ] }, { typeId: optionIds.topicTypeExclusionId, title: 'Exclusion', items: ['Civil work by customer', 'Power feeder by customer'] }, { typeId: optionIds.topicTypePaymentId, title: 'Payment', items: ['50% upon PO', '40% before delivery', '10% after commissioning'] }, { typeId: 'warranty', title: 'Warranty', items: ['12 months after commissioning', 'Consumables excluded'] }, { typeId: 'delivery', title: 'Delivery', items: ['Within 60 days after approved drawing', 'Subject to final site readiness'] } ] : [ { typeId: optionIds.topicTypeScopeId, title: 'Scope of work', items: ['Supply crane equipment', 'Install and test the system'] }, { typeId: optionIds.topicTypeExclusionId, title: 'Exclusions', items: ['Civil work by customer', 'Power feeder by customer'] }, { typeId: optionIds.topicTypePaymentId, title: 'Payment terms', items: ['50% upon PO', '40% before delivery', '10% after commissioning'] } ]; for (const [topicIndex, topic] of topics.entries()) { const topicId = crypto.randomUUID(); await sql` insert into crm_quotation_topics ( id, organization_id, quotation_id, topic_type, title, sort_order ) values ( ${topicId}, ${organization.id}, ${quotationId}, ${topic.typeId}, ${topic.title}, ${topicIndex + 1} ) `; for (const [itemIndex, content] of topic.items.entries()) { await sql` insert into crm_quotation_topic_items ( id, organization_id, topic_id, content, sort_order ) values ( ${crypto.randomUUID()}, ${organization.id}, ${topicId}, ${content}, ${itemIndex + 1} ) `; } } if (approved) { const approvalRequestId = crypto.randomUUID(); await sql` insert into crm_approval_requests ( id, organization_id, workflow_id, entity_type, entity_id, current_step, status, requested_by, requested_at, completed_at ) values ( ${approvalRequestId}, ${organization.id}, ${workflow.id}, 'quotation', ${quotationId}, ${workflow.steps[workflow.steps.length - 1].stepNumber}, 'approved', ${creatorId}, ${'2026-06-16T00:30:00.000Z'}, ${'2026-06-16T01:30:00.000Z'} ) `; await sql` insert into crm_approval_actions ( id, organization_id, approval_request_id, step_number, action, remark, acted_by, acted_at ) values ( ${crypto.randomUUID()}, ${organization.id}, ${approvalRequestId}, 0, 'submit', 'Task H.1 fixture submission', ${creatorId}, ${'2026-06-16T00:30:00.000Z'} ) `; for (const [index, step] of workflow.steps.entries()) { await sql` insert into crm_approval_actions ( id, organization_id, approval_request_id, step_number, action, remark, acted_by, acted_at ) values ( ${crypto.randomUUID()}, ${organization.id}, ${approvalRequestId}, ${step.stepNumber}, 'approve', ${`Task H.1 fixture ${step.roleCode} approval`}, ${creatorId}, ${new Date(Date.UTC(2026, 5, 16, 1, index * 10, 0)).toISOString()} ) `; } } return quotationId; } async function main() { loadLocalEnv(); const sql = postgres(requireEnv('DATABASE_URL'), { prepare: false }); try { const organization = ( await sql` select id, name, slug, created_by as "createdBy" from organizations order by created_at asc limit 1 ` )[0] ?? null; if (!organization) { throw new Error('No organization found for Task H.1 fixture'); } const creatorId = ( await sql` select id from users where system_role = 'super_admin' order by created_at asc limit 1 ` )[0]?.id ?? organization.createdBy; if (!creatorId) { throw new Error('No creator user found for Task H.1 fixture'); } const optionIds = { branchId: await resolveOptionId(sql, organization.id, 'crm_branch', 'head_office'), customerTypeCompanyId: await resolveOptionId( sql, organization.id, 'crm_customer_type', 'company' ), customerStatusActiveId: await resolveOptionId( sql, organization.id, 'crm_customer_status', 'active' ), leadChannelSalesId: await resolveOptionId(sql, organization.id, 'crm_lead_channel', 'sales'), customerGroupStandardId: await resolveOptionId( sql, organization.id, 'crm_customer_group', 'standard' ), quotationStatusDraftId: await resolveOptionId( sql, organization.id, 'crm_quotation_status', 'draft' ), quotationStatusApprovedId: await resolveOptionId( sql, organization.id, 'crm_quotation_status', 'approved' ), quotationTypeServiceId: await resolveOptionId( sql, organization.id, 'crm_quotation_type', 'service' ), currencyThbId: await resolveOptionId(sql, organization.id, 'crm_currency', 'THB'), discountPercentageId: await resolveOptionId( sql, organization.id, 'crm_discount_type', 'percentage' ), unitSetId: await resolveOptionId(sql, organization.id, 'crm_unit', 'set'), productTypeCraneId: await resolveOptionId( sql, organization.id, 'crm_product_type', 'crane' ), customerRoleCustomerId: await resolveOptionId( sql, organization.id, 'crm_project_party_role', 'customer' ), customerRoleBillingCustomerId: await resolveOptionId( sql, organization.id, 'crm_project_party_role', 'billing_customer' ), customerRoleConsultantId: await resolveOptionId( sql, organization.id, 'crm_project_party_role', 'consultant' ), topicTypeScopeId: await resolveOptionId( sql, organization.id, 'crm_quotation_topic_type', 'scope' ), topicTypeExclusionId: await resolveOptionId( sql, organization.id, 'crm_quotation_topic_type', 'exclusion' ), topicTypePaymentId: await resolveOptionId( sql, organization.id, 'crm_quotation_topic_type', 'payment' ), sentViaEmailId: await resolveOptionId(sql, organization.id, 'crm_sent_via', 'email') }; const workflow = await resolveApprovalWorkflow(sql, organization.id); const { customerId, contactId } = await ensureCustomerAndContact( sql, organization, creatorId, optionIds ); const billingCustomerId = await ensureRelatedCustomer(sql, organization, creatorId, optionIds, { code: FIXTURE_CODES.billingCustomer, name: 'Task H.5 Billing Customer', abbr: 'TASKH5BILL', taxId: '0105559002233', address: '88 Audit Park Road, Bangkok 10240', phone: '02-000-2000', email: 'billing-customer@local.test', notes: 'Billing customer for Task H.5 parity fixture' }); const consultantCustomerId = await ensureRelatedCustomer( sql, organization, creatorId, optionIds, { code: FIXTURE_CODES.consultantCustomer, name: 'Task H.5 Consultant', abbr: 'TASKH5CONS', taxId: '0105559003344', address: '77 Review Lane, Bangkok 10110', phone: '02-000-3000', email: 'consultant-customer@local.test', notes: 'Consultant customer for Task H.5 parity fixture' } ); const users = { admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', { role: 'admin', businessRole: 'sales_manager', permissions: [...PDF_PERMISSIONS, 'users:manage'] }), pdfUser: await ensureUser( sql, organization, creatorId, FIXTURE_USERS.pdfUser, 'Task H.1 PDF User', { role: 'user', businessRole: 'sales_support', permissions: PDF_PERMISSIONS } ), viewer: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.viewer, 'Task H.1 Viewer', { role: 'user', businessRole: 'sales_support', permissions: ['crm.quotation.read'] }) }; const approvedQuotationId = await upsertQuotation(sql, { organization, creatorId, workflow, optionIds, customerId, contactId, code: FIXTURE_CODES.approvedQuotation, approved: true }); const draftQuotationId = await upsertQuotation(sql, { organization, creatorId, workflow, optionIds, customerId, contactId, code: FIXTURE_CODES.draftQuotation, approved: false }); const auditQuotationId = await upsertQuotation(sql, { organization, creatorId: users.admin.id, workflow, optionIds, customerId, contactId, code: FIXTURE_CODES.auditQuotation, approved: true, fixtureProfile: 'h5', projectParties: [ { customerId, role: optionIds.customerRoleCustomerId, isPrimary: true, remark: 'End customer' }, { customerId: billingCustomerId, role: optionIds.customerRoleBillingCustomerId, isPrimary: false, remark: 'Billing customer' }, { customerId: consultantCustomerId, role: optionIds.customerRoleConsultantId, isPrimary: false, remark: 'Consultant' } ] }); console.log( JSON.stringify( { organizationId: organization.id, approvedQuotationId, approvedQuotationCode: FIXTURE_CODES.approvedQuotation, draftQuotationId, draftQuotationCode: FIXTURE_CODES.draftQuotation, auditQuotationId, auditQuotationCode: FIXTURE_CODES.auditQuotation, users, password: TASK_H1_PASSWORD }, null, 2 ) ); } finally { await sql.end(); } } main().catch((error) => { console.error('[seed-task-h1-fixture] Failed:', error.message); process.exitCode = 1; });