task-h.1 complate

This commit is contained in:
phaichayon
2026-06-16 14:25:26 +07:00
parent edee45375e
commit 9ae41e4f2c
14 changed files with 2238 additions and 201 deletions

View File

@@ -0,0 +1,955 @@
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'
};
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'
];
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 upsertQuotation(sql, args) {
const {
organization,
creatorId,
workflow,
optionIds,
customerId,
contactId,
code,
approved
} = 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 = approved
? 'Approved quotation fixture for Task H.1 PDF stabilization.'
: 'Draft quotation fixture for Task H.1 negative API validation.';
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 = ${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'},
project_location = 'Bangkok HQ',
attention = 'Task H.1 Contact',
reference = ${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
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},
${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'},
'Bangkok HQ',
'Task H.1 Contact',
${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
${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}`;
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},
1,
${optionIds.productTypeCraneId},
'1 set overhead crane with installation and commissioning',
1,
${optionIds.unitSetId},
125000,
5000,
${optionIds.discountPercentageId},
7,
120000,
'Smoke-test commercial line',
1
)
`;
await sql`
insert into crm_quotation_customers (
id,
organization_id,
quotation_id,
customer_id,
role,
is_primary
) values (
${crypto.randomUUID()},
${organization.id},
${quotationId},
${customerId},
${optionIds.customerRoleOwnerId},
true
)
`;
const topics = [
{
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'
),
customerRoleOwnerId: await resolveOptionId(
sql,
organization.id,
'crm_quotation_customer_role',
'owner'
),
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 users = {
admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', {
role: 'admin',
businessRole: 'it_admin',
permissions: [...PDF_PERMISSIONS, 'users:manage']
}),
pdfUser: await ensureUser(
sql,
organization,
creatorId,
FIXTURE_USERS.pdfUser,
'Task H.1 PDF User',
{
role: 'user',
businessRole: 'viewer',
permissions: PDF_PERMISSIONS
}
),
viewer: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.viewer, 'Task H.1 Viewer', {
role: 'user',
businessRole: 'viewer',
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
});
console.log(
JSON.stringify(
{
organizationId: organization.id,
approvedQuotationId,
approvedQuotationCode: FIXTURE_CODES.approvedQuotation,
draftQuotationId,
draftQuotationCode: FIXTURE_CODES.draftQuotation,
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;
});