1224 lines
30 KiB
JavaScript
1224 lines
30 KiB
JavaScript
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;
|
|
});
|