Files
alla-tms/scripts/seed-uat-data.js
2026-07-16 09:53:14 +07:00

1407 lines
45 KiB
JavaScript

const crypto = require("node:crypto");
const fsSync = require("node:fs");
const fs = require("node:fs/promises");
const path = require("node:path");
const postgres = require("postgres");
const { hash } = require("bcryptjs");
function loadEnvFile() {
const envPath = path.join(process.cwd(), ".env");
if (!fsSync.existsSync(envPath)) {
return;
}
const raw = fsSync.readFileSync(envPath, "utf8");
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex <= 0) continue;
const key = trimmed.slice(0, separatorIndex).trim();
const value = trimmed.slice(separatorIndex + 1).trim();
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
loadEnvFile();
const DEFAULT_CONNECTION_STRING =
process.env.DATABASE_URL ||
"postgres://postgres:postgres@localhost:5432/training_system";
const THAI_YEAR = 2569;
const GREGORIAN_YEAR = THAI_YEAR - 543;
const ORG_SLUG = "uat-2569";
const ORG_NAME = "ชุดข้อมูล UAT 2569";
const OWNER_EMAIL = "uat2569.hrd@training.local";
const EMPLOYEE_PASSWORD = "UAT12345!";
const OWNER_PASSWORD = "UAT12345!";
const EMPLOYEE_EMAIL_DOMAIN = "training.local";
const SEED_PREFIX = "[UAT2569]";
const OWNER_PERMISSIONS = [
"products:read",
"products:write",
"organization:manage",
"users:manage",
];
const MEMBER_PERMISSIONS = ["products:read"];
const DEPARTMENTS = [
{ code: "IT", name: "IT" },
{ code: "HRD", name: "HRD" },
{ code: "ACC", name: "Accounting" },
{ code: "SAL", name: "Sales" },
{ code: "ENG", name: "Engineering" },
{ code: "OPS", name: "Operations" },
];
const POSITIONS = [
{ code: "MGR", name: "Manager" },
{ code: "SUP", name: "Supervisor" },
{ code: "SNR", name: "Senior Officer" },
{ code: "OFF", name: "Officer" },
{ code: "SPC", name: "Specialist" },
{ code: "ENG", name: "Engineer" },
];
const FIRST_NAMES = [
"กิตติ",
"กานต์",
"เกรียงไกร",
"ขวัญชัย",
"คมสัน",
"จตุพล",
"จักรพันธ์",
"ชยพล",
"ณัฐพงศ์",
"ณัฐวุฒิ",
"ดนัย",
"ธนกร",
"ธนพล",
"ธีรภัทร",
"ธีรวัฒน์",
"นพดล",
"นราธิป",
"นันทชัย",
"บวร",
"ประภาส",
"ปริญญา",
"ปัญญา",
"พงศกร",
"พชร",
"พีรภัทร",
"ภาณุ",
"มนัส",
"มงคล",
"ยศกร",
"รัฐพล",
"วรพล",
"วัชรินทร์",
"วิชญ์",
"ศรัณย์",
"ศุภชัย",
"สกล",
"สถาพร",
"สมภพ",
"สุรชัย",
"อนุชา",
"อภิสิทธิ์",
"อภินันท์",
"อรรถพล",
"อัครเดช",
"อาทิตย์",
"หญิงนุช",
"กัลยา",
"จิราพร",
"ชุติมา",
"ณัฐกานต์",
"ดวงพร",
"ทิพวรรณ",
"ธนพร",
"นภัสสร",
"บงกช",
"ปาริฉัตร",
"พิมพ์ชนก",
"ภัทรา",
"มธุรส",
"รัชนี",
"วรางคณา",
"ศศิธร",
"ศิริพร",
"สุชาดา",
"สุพัตรา",
"อรทัย",
"อัจฉรา",
];
const LAST_NAMES = [
"กาญจนศรี",
"เกษมสุข",
"แก้วมณี",
"ขันทอง",
"คงมั่น",
"จันทร์เพ็ญ",
"เจริญกิจ",
"ชูเกียรติ",
"ชัยมงคล",
"ณรงค์เดช",
"ดวงดี",
"ทองใบ",
"ทองสุข",
"ทิพย์มณี",
"นาคะเสถียร",
"บุญมาก",
"บุญรอด",
"ประเสริฐศรี",
"ปรีชานนท์",
"ผ่องใส",
"พงษ์พิทักษ์",
"พิพัฒน์กุล",
"ภักดี",
"มณีวงศ์",
"มหาศาล",
"มีทรัพย์",
"รัตนโชติ",
"รุ่งเรือง",
"ลือชา",
"วัฒนากุล",
"วงศ์สวัสดิ์",
"ศรีจันทร์",
"ศรีสุข",
"สกุลไทย",
"สถาพรพงศ์",
"สมบูรณ์ทรัพย์",
"สวัสดิ์ผล",
"สิงห์ทอง",
"สุขเกษม",
"สุทธิพงศ์",
"อารยธรรม",
"อินทรา",
];
const COURSE_TEMPLATES = [
["UAT2569-C001", "พื้นฐาน Cybersecurity สำหรับพนักงาน", "K", "IT Academy", 6],
["UAT2569-C002", "การใช้งาน Microsoft Excel ขั้นกลาง", "S", "Learning Hub", 6],
["UAT2569-C003", "PDPA สำหรับหน่วยงานธุรกิจ", "K", "Compliance Center", 3],
["UAT2569-C004", "การสื่อสารอย่างมีประสิทธิภาพ", "S", "People Academy", 4],
["UAT2569-C005", "5ส และการจัดระเบียบพื้นที่ทำงาน", "A", "Operations Academy", 3],
["UAT2569-C006", "เทคนิคการเจรจาต่อรองการขาย", "S", "Sales Academy", 6],
["UAT2569-C007", "พื้นฐาน SQL สำหรับงานวิเคราะห์", "K", "Data Lab", 6],
["UAT2569-C008", "ภาวะผู้นำสำหรับหัวหน้างาน", "A", "Leadership Center", 6],
["UAT2569-C009", "การควบคุมเอกสาร ISO 9001", "K", "Quality Academy", 4],
["UAT2569-C010", "Root Cause Analysis", "S", "Engineering Academy", 6],
["UAT2569-C011", "ความปลอดภัยในการทำงานภาคสนาม", "A", "Safety Center", 3],
["UAT2569-C012", "การวางแผนกำลังคนและพัฒนาองค์กร", "K", "HRD Academy", 6],
["UAT2569-C013", "การวิเคราะห์ต้นทุนการผลิต", "K", "Finance Academy", 6],
["UAT2569-C014", "การใช้งาน Power BI เบื้องต้น", "S", "Data Lab", 6],
["UAT2569-C015", "Service Mind สำหรับทีมสนับสนุน", "A", "People Academy", 4],
["UAT2569-C016", "ITIL Foundation for Support Team", "K", "IT Academy", 8],
["UAT2569-C017", "การบริหารคลังสินค้าและรหัสสินค้า", "S", "Operations Academy", 6],
["UAT2569-C018", "การอ่านงบการเงินสำหรับผู้จัดการ", "K", "Finance Academy", 4],
["UAT2569-C019", "เทคนิคการนำเสนอผลงาน", "S", "Learning Hub", 4],
["UAT2569-C020", "การคิดเชิงปรับปรุงอย่างต่อเนื่อง", "A", "Continuous Improvement", 4],
["UAT2569-C021", "Secure Coding Awareness", "K", "IT Academy", 6],
["UAT2569-C022", "ภาษีมูลค่าเพิ่มสำหรับนักบัญชี", "K", "Finance Academy", 6],
["UAT2569-C023", "การใช้ CRM เพื่อเพิ่มยอดขาย", "S", "Sales Academy", 6],
["UAT2569-C024", "Lean Manufacturing Basics", "K", "Engineering Academy", 6],
["UAT2569-C025", "การให้ Feedback กับทีมงาน", "A", "Leadership Center", 4],
["UAT2569-C026", "การบริหารความเสี่ยงองค์กร", "K", "Compliance Center", 4],
["UAT2569-C027", "Advanced Excel for Reporting", "S", "Learning Hub", 6],
["UAT2569-C028", "พื้นฐานการจัดซื้อจัดจ้าง", "K", "Operations Academy", 4],
["UAT2569-C029", "การบริหารข้อร้องเรียนลูกค้า", "A", "Sales Academy", 4],
["UAT2569-C030", "PLC Maintenance Essentials", "S", "Engineering Academy", 8],
["UAT2569-C031", "People Analytics สำหรับ HRD", "K", "HRD Academy", 6],
["UAT2569-C032", "Negotiation for Key Account", "S", "Sales Academy", 8],
["UAT2569-C033", "Project Management Fundamentals", "K", "PM Academy", 6],
["UAT2569-C034", "Coaching Skills for Supervisor", "A", "Leadership Center", 6],
["UAT2569-C035", "ความปลอดภัยข้อมูลและรหัสผ่าน", "A", "IT Academy", 2],
["UAT2569-C036", "Kaizen Workshop", "S", "Continuous Improvement", 6],
["UAT2569-C037", "Internal Audit Techniques", "K", "Quality Academy", 6],
["UAT2569-C038", "Presentation with Data Storytelling", "S", "Data Lab", 6],
["UAT2569-C039", "Teamwork and Collaboration", "A", "People Academy", 4],
["UAT2569-C040", "Warehouse Safety and Forklift Awareness", "A", "Safety Center", 4],
];
const ANNOUNCEMENT_TEMPLATES = [
"กำหนดการอบรมความปลอดภัยประจำปี",
"แจ้งแนวทางยื่นประวัติการอบรมภายในไตรมาส 1",
"ประชาสัมพันธ์หลักสูตร Leadership รอบเดือนกุมภาพันธ์",
"เปลี่ยนแปลงเกณฑ์อนุมัติชั่วโมงอบรม",
"ประกาศวันปิดระบบปรับปรุงฐานข้อมูล",
"เชิญชวนเข้าร่วมหลักสูตร Power BI สำหรับสายงานสนับสนุน",
"แจ้งเตือนการอัปโหลดใบรับรองหลังอบรม",
"กำหนดการทดสอบ Browser UAT รอบฝ่าย HRD",
"ประกาศผลการทบทวนหลักสูตรบังคับประจำปี",
"แจ้งปรับปรุงคู่มือใช้งานระบบ TMS",
"เชิญหัวหน้างานเข้าร่วม Workshop Coaching Skills",
"ประกาศหลักเกณฑ์การนับชั่วโมง K/S/A",
"แจ้งเปิดรับข้อเสนอแนะก่อน Sprint 7",
"ตารางอบรม Mandatory Training รอบ Q3",
"แจ้งกำหนดการซ้อมแผน BCP และความต่อเนื่องทางธุรกิจ",
];
const IMPORT_TEMPLATE_NAMES = [
"employee-batch-q1.xlsx",
"employee-batch-q2.xlsx",
"employee-batch-q3.xlsx",
];
function createRng(seed) {
let state = seed >>> 0;
return () => {
state = (1664525 * state + 1013904223) >>> 0;
return state / 4294967296;
};
}
const random = createRng(2569);
function pick(list) {
return list[Math.floor(random() * list.length)];
}
function randomInt(min, max) {
return Math.floor(random() * (max - min + 1)) + min;
}
function chunk(list, size) {
const chunks = [];
for (let index = 0; index < list.length; index += size) {
chunks.push(list.slice(index, index + size));
}
return chunks;
}
function makeThaiDate(month, day) {
return new Date(Date.UTC(GREGORIAN_YEAR, month, day, 9, 0, 0));
}
function formatMonthKey(date) {
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
}
function slugify(value) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function toPgTextArray(values) {
return `{${values
.map((value) => `"${String(value).replace(/"/g, '\\"')}"`)
.join(",")}}`;
}
function uniqueNames(count) {
const result = [];
const used = new Set();
for (const firstName of FIRST_NAMES) {
for (const lastName of LAST_NAMES) {
const fullName = `${firstName} ${lastName}`;
if (used.has(fullName)) continue;
used.add(fullName);
result.push(fullName);
if (result.length === count) {
return result;
}
}
}
throw new Error("Not enough unique Thai names for seed generation");
}
async function ensureOwner(sql) {
const passwordHash = await hash(OWNER_PASSWORD, 10);
const [existing] = await sql`
select id
from users
where email = ${OWNER_EMAIL}
limit 1
`;
if (existing) {
await sql`
update users
set
name = ${"หัวหน้าฝ่าย HRD UAT"},
password_hash = ${passwordHash},
system_role = 'standard',
is_active = true,
updated_at = now()
where id = ${existing.id}
`;
return existing.id;
}
const userId = crypto.randomUUID();
await sql`
insert into users (
id,
name,
email,
password_hash,
system_role,
is_active,
active_organization_id
)
values (
${userId},
${"หัวหน้าฝ่าย HRD UAT"},
${OWNER_EMAIL},
${passwordHash},
'standard',
true,
null
)
`;
return userId;
}
async function ensureOrganization(sql, ownerUserId) {
const [existing] = await sql`
select id
from organizations
where slug = ${ORG_SLUG}
limit 1
`;
if (existing) {
await sql`
update users
set active_organization_id = ${existing.id}, updated_at = now()
where id = ${ownerUserId}
`;
await sql`
insert into memberships (
id,
user_id,
organization_id,
role,
permissions
)
values (
${crypto.randomUUID()},
${ownerUserId},
${existing.id},
'owner',
${toPgTextArray(OWNER_PERMISSIONS)}
)
on conflict (user_id, organization_id) do update
set role = 'owner', permissions = ${toPgTextArray(OWNER_PERMISSIONS)}, updated_at = now()
`;
return existing.id;
}
const organizationId = crypto.randomUUID();
await sql`
insert into organizations (
id,
name,
slug,
plan,
pending_master_review,
created_by
)
values (
${organizationId},
${ORG_NAME},
${ORG_SLUG},
'free',
false,
${ownerUserId}
)
`;
await sql`
update users
set active_organization_id = ${organizationId}, updated_at = now()
where id = ${ownerUserId}
`;
await sql`
insert into memberships (
id,
user_id,
organization_id,
role,
permissions
)
values (
${crypto.randomUUID()},
${ownerUserId},
${organizationId},
'owner',
${toPgTextArray(OWNER_PERMISSIONS)}
)
on conflict (user_id, organization_id) do update
set role = 'owner', permissions = ${toPgTextArray(OWNER_PERMISSIONS)}, updated_at = now()
`;
return organizationId;
}
async function cleanupOrganization(sql, organizationId, ownerUserId) {
const members = await sql`
select user_id
from memberships
where organization_id = ${organizationId}
and user_id <> ${ownerUserId}
`;
const memberIds = members.map((row) => row.user_id);
await sql`delete from notifications where organization_id = ${organizationId}`;
await sql`delete from audit_logs where organization_id = ${organizationId}`;
await sql`delete from announcements where organization_id = ${organizationId}`;
await sql`delete from certificates where organization_id = ${organizationId}`;
await sql`delete from training_records where organization_id = ${organizationId}`;
await sql`delete from import_jobs where organization_id = ${organizationId}`;
await sql`delete from training_matrices where organization_id = ${organizationId}`;
await sql`delete from courses where organization_id = ${organizationId}`;
await sql`delete from training_policies where organization_id = ${organizationId}`;
await sql`delete from departments where organization_id = ${organizationId}`;
await sql`delete from positions where organization_id = ${organizationId}`;
if (memberIds.length > 0) {
await sql`
delete from memberships
where organization_id = ${organizationId}
and user_id <> ${ownerUserId}
`;
await sql`
delete from users
where id = any(${sql.array(memberIds, "text")})
`;
}
}
async function seedDepartmentsAndPositions(sql, organizationId) {
for (const department of DEPARTMENTS) {
await sql`
insert into departments (
organization_id,
code,
name,
description,
is_active,
pending_master_review
)
values (
${organizationId},
${department.code},
${department.name},
${`${department.name} department for UAT`}
,
true,
false
)
on conflict (organization_id, name) do update
set code = excluded.code, description = excluded.description, is_active = true, pending_master_review = false, updated_at = now()
`;
}
for (const position of POSITIONS) {
await sql`
insert into positions (
organization_id,
code,
name,
description,
is_active,
pending_master_review
)
values (
${organizationId},
${position.code},
${position.name},
${`${position.name} role for UAT`}
,
true,
false
)
on conflict (organization_id, name) do update
set code = excluded.code, description = excluded.description, is_active = true, pending_master_review = false, updated_at = now()
`;
}
const departments = await sql`
select id, name
from departments
where organization_id = ${organizationId}
order by name asc
`;
const positions = await sql`
select id, name
from positions
where organization_id = ${organizationId}
order by name asc
`;
return {
departments,
positions,
};
}
async function seedTrainingPolicy(sql, organizationId) {
await sql`
insert into training_policies (
organization_id,
year,
total_hours,
k_hours,
s_hours,
a_hours,
is_active
)
values (
${organizationId},
${GREGORIAN_YEAR},
30,
12,
12,
6,
true
)
on conflict (organization_id, year) do update
set
total_hours = excluded.total_hours,
k_hours = excluded.k_hours,
s_hours = excluded.s_hours,
a_hours = excluded.a_hours,
is_active = true,
updated_at = now()
`;
}
async function seedEmployees(sql, organizationId, ownerUserId, departmentMap, positionMap) {
const names = uniqueNames(50);
const passwordHash = await hash(EMPLOYEE_PASSWORD, 10);
const distribution = {
IT: 8,
HRD: 8,
Accounting: 8,
Sales: 8,
Engineering: 9,
Operations: 9,
};
const positionNames = [
"Manager",
"Supervisor",
"Senior Officer",
"Officer",
"Specialist",
"Engineer",
];
const employees = [];
let runningIndex = 0;
for (const [departmentName, count] of Object.entries(distribution)) {
for (let localIndex = 0; localIndex < count; localIndex += 1) {
const userId = crypto.randomUUID();
const employeeNumber = String(runningIndex + 1).padStart(3, "0");
const email = `uat2569.employee${employeeNumber}@${EMPLOYEE_EMAIL_DOMAIN}`;
const employeeCode = `UAT2569-${employeeNumber}`;
const hiredAt = makeThaiDate(
randomInt(0, 11),
randomInt(1, 25)
);
hiredAt.setUTCFullYear(GREGORIAN_YEAR - randomInt(0, 4));
const positionName =
departmentName === "Engineering"
? pick(["Engineer", "Senior Officer", "Supervisor"])
: departmentName === "IT"
? pick(["Specialist", "Senior Officer", "Officer"])
: pick(positionNames);
await sql`
insert into users (
id,
name,
email,
password_hash,
system_role,
is_active,
employee_code,
department_id,
position_id,
company_name,
hired_at,
active_organization_id
)
values (
${userId},
${names[runningIndex]},
${email},
${passwordHash},
'standard',
true,
${employeeCode},
${departmentMap.get(departmentName)},
${positionMap.get(positionName)},
${ORG_NAME},
${hiredAt},
${organizationId}
)
`;
await sql`
insert into memberships (
id,
user_id,
organization_id,
role,
permissions
)
values (
${crypto.randomUUID()},
${userId},
${organizationId},
'member',
${toPgTextArray(MEMBER_PERMISSIONS)}
)
`;
employees.push({
id: userId,
name: names[runningIndex],
email,
employeeCode,
departmentName,
positionName,
});
runningIndex += 1;
}
}
return employees;
}
async function seedCourses(sql, organizationId) {
const courses = [];
for (const [courseCode, name, category, organizer, standardHours] of COURSE_TEMPLATES) {
const [course] = await sql`
insert into courses (
organization_id,
course_code,
name,
category,
organizer,
standard_hours,
certificate_required,
is_required,
description,
is_active
)
values (
${organizationId},
${courseCode},
${name},
${category},
${organizer},
${standardHours},
true,
${category === "K"},
${`${SEED_PREFIX} ${name}`},
true
)
returning id, name, category, organizer, standard_hours
`;
courses.push({
id: course.id,
name: course.name,
category,
organizer: course.organizer,
standardHours: Number(course.standard_hours),
});
}
return courses;
}
function buildCoursePools(courses) {
const byName = new Map(courses.map((course) => [course.name, course]));
return {
IT: [
byName.get("พื้นฐาน Cybersecurity สำหรับพนักงาน"),
byName.get("พื้นฐาน SQL สำหรับงานวิเคราะห์"),
byName.get("ITIL Foundation for Support Team"),
byName.get("Secure Coding Awareness"),
byName.get("ความปลอดภัยข้อมูลและรหัสผ่าน"),
byName.get("Project Management Fundamentals"),
].filter(Boolean),
HRD: [
byName.get("การวางแผนกำลังคนและพัฒนาองค์กร"),
byName.get("People Analytics สำหรับ HRD"),
byName.get("การสื่อสารอย่างมีประสิทธิภาพ"),
byName.get("การให้ Feedback กับทีมงาน"),
byName.get("Coaching Skills for Supervisor"),
byName.get("Teamwork and Collaboration"),
].filter(Boolean),
Accounting: [
byName.get("การวิเคราะห์ต้นทุนการผลิต"),
byName.get("การอ่านงบการเงินสำหรับผู้จัดการ"),
byName.get("ภาษีมูลค่าเพิ่มสำหรับนักบัญชี"),
byName.get("PDPA สำหรับหน่วยงานธุรกิจ"),
byName.get("Advanced Excel for Reporting"),
byName.get("การบริหารความเสี่ยงองค์กร"),
].filter(Boolean),
Sales: [
byName.get("เทคนิคการเจรจาต่อรองการขาย"),
byName.get("การใช้ CRM เพื่อเพิ่มยอดขาย"),
byName.get("Negotiation for Key Account"),
byName.get("เทคนิคการนำเสนอผลงาน"),
byName.get("การบริหารข้อร้องเรียนลูกค้า"),
byName.get("Presentation with Data Storytelling"),
].filter(Boolean),
Engineering: [
byName.get("Root Cause Analysis"),
byName.get("Lean Manufacturing Basics"),
byName.get("PLC Maintenance Essentials"),
byName.get("Kaizen Workshop"),
byName.get("Project Management Fundamentals"),
byName.get("Internal Audit Techniques"),
].filter(Boolean),
Operations: [
byName.get("5ส และการจัดระเบียบพื้นที่ทำงาน"),
byName.get("การบริหารคลังสินค้าและรหัสสินค้า"),
byName.get("พื้นฐานการจัดซื้อจัดจ้าง"),
byName.get("ความปลอดภัยในการทำงานภาคสนาม"),
byName.get("Warehouse Safety and Forklift Awareness"),
byName.get("การคิดเชิงปรับปรุงอย่างต่อเนื่อง"),
].filter(Boolean),
};
}
async function seedTrainingRecords(sql, organizationId, ownerUserId, employees, courses) {
const coursePools = buildCoursePools(courses);
const countsByDepartment = {
IT: 35,
HRD: 30,
Accounting: 35,
Sales: 45,
Engineering: 55,
Operations: 50,
};
const approvedMonthlyTargets = [8, 10, 11, 12, 13, 14, 15, 17, 16, 15, 14, 15];
const approvedCategoryCycle = [
"K", "S", "K", "A", "S", "K", "S", "K", "A", "K",
];
let approvedMonthIndex = 0;
let approvedCategoryIndex = 0;
const trainingRecords = [];
const statusSummary = { approved: 0, pending: 0, rejected: 0 };
const departmentSummary = {};
const monthlyApprovedSummary = {};
const categorySummary = { K: 0, S: 0, A: 0 };
const courseSummary = new Map();
for (const departmentName of Object.keys(countsByDepartment)) {
const departmentEmployees = employees.filter(
(employee) => employee.departmentName === departmentName
);
const totalRecords = countsByDepartment[departmentName];
for (let index = 0; index < totalRecords; index += 1) {
const employee = departmentEmployees[index % departmentEmployees.length];
const course = pick(coursePools[departmentName]);
const statusRoll = random();
const status =
statusRoll < 0.64 ? "approved" : statusRoll < 0.86 ? "pending" : "rejected";
let trainingDate;
if (status === "approved") {
const monthTarget = approvedMonthlyTargets[approvedMonthIndex % approvedMonthlyTargets.length];
const month = approvedMonthIndex % 12;
const positionInMonth =
Object.values(monthlyApprovedSummary).reduce((sum, value) => sum + value, 0);
void positionInMonth;
trainingDate = makeThaiDate(month, randomInt(2, 24));
approvedMonthlyTargets[approvedMonthIndex % approvedMonthlyTargets.length] = monthTarget - 1;
if (approvedMonthlyTargets[approvedMonthIndex % approvedMonthlyTargets.length] === 0) {
approvedMonthIndex += 1;
}
} else {
trainingDate = makeThaiDate(randomInt(0, 11), randomInt(1, 25));
}
const hours = Number(
(course.standardHours + (random() < 0.35 ? 1 : 0)).toFixed(1)
);
const category =
status === "approved"
? approvedCategoryCycle[approvedCategoryIndex++ % approvedCategoryCycle.length]
: null;
const approvedHours =
status === "approved"
? Number(
Math.max(hours - (random() < 0.2 ? 1 : 0), 1).toFixed(1)
)
: null;
const reviewedAt =
status === "pending"
? null
: new Date(trainingDate.getTime() + randomInt(2, 15) * 24 * 60 * 60 * 1000);
const reviewerNote =
status === "approved"
? pick([
"ผ่านเกณฑ์และนับชั่วโมงได้ตามหลักสูตร",
"อนุมัติตามเอกสารและขอบเขตงาน",
"ผลการอบรมสอดคล้องกับแผนพัฒนารายบุคคล",
])
: null;
const rejectReason =
status === "rejected"
? pick([
"เอกสารแนบไม่ครบถ้วน",
"หัวข้ออบรมไม่ตรงกับนโยบายปี 2569",
"ชั่วโมงอบรมไม่สามารถยืนยันได้",
])
: null;
const [record] = await sql`
insert into training_records (
organization_id,
user_id,
course_id,
course_name,
training_date,
training_type,
hours,
approved_hours,
category,
organizer,
note,
approval_status,
reviewer_note,
reject_reason,
reviewed_by,
reviewed_at,
created_by
)
values (
${organizationId},
${employee.id},
${course.id},
${course.name},
${trainingDate},
${pick(["online", "onsite", "internal", "external"])},
${hours},
${approvedHours},
${category},
${course.organizer},
${`${SEED_PREFIX} บันทึกการอบรมของ ${employee.name}`},
${status},
${reviewerNote},
${rejectReason},
${status === "pending" ? null : ownerUserId},
${reviewedAt},
${employee.id}
)
returning id, course_name, approval_status, approved_hours, hours, category, user_id, training_date
`;
trainingRecords.push({
id: record.id,
userId: employee.id,
courseName: record.course_name,
status: record.approval_status,
approvedHours: record.approved_hours === null ? null : Number(record.approved_hours),
hours: Number(record.hours),
category: record.category,
trainingDate: record.training_date,
departmentName,
});
statusSummary[status] += 1;
departmentSummary[departmentName] = (departmentSummary[departmentName] || 0) + 1;
if (status === "approved") {
const monthKey = formatMonthKey(record.training_date);
monthlyApprovedSummary[monthKey] = (monthlyApprovedSummary[monthKey] || 0) + 1;
categorySummary[category] += Number(record.approved_hours ?? record.hours);
courseSummary.set(
record.course_name,
(courseSummary.get(record.course_name) || 0) +
Number(record.approved_hours ?? record.hours)
);
}
}
}
return {
trainingRecords,
statusSummary,
departmentSummary,
monthlyApprovedSummary,
categorySummary,
courseSummary: [...courseSummary.entries()]
.sort((left, right) => right[1] - left[1])
.slice(0, 5),
};
}
async function seedAnnouncements(sql, organizationId, ownerUserId) {
const announcements = [];
const statusPlan = [
"published", "published", "published", "published", "published",
"published", "published", "published", "published", "published",
"draft", "draft", "draft", "archived", "archived",
];
for (let index = 0; index < ANNOUNCEMENT_TEMPLATES.length; index += 1) {
const status = statusPlan[index];
const startDate =
index < 8
? makeThaiDate(Math.max(index - 1, 0), randomInt(1, 10))
: makeThaiDate(randomInt(0, 11), randomInt(1, 15));
const endDate =
status === "archived"
? new Date(startDate.getTime() + 40 * 24 * 60 * 60 * 1000)
: index % 3 === 0
? null
: new Date(startDate.getTime() + randomInt(30, 120) * 24 * 60 * 60 * 1000);
const [announcement] = await sql`
insert into announcements (
organization_id,
title,
content,
start_date,
end_date,
is_pin,
status,
created_by,
updated_by
)
values (
${organizationId},
${`${SEED_PREFIX} ${ANNOUNCEMENT_TEMPLATES[index]}`},
${`${ANNOUNCEMENT_TEMPLATES[index]} สำหรับการทดสอบระบบ TMS ในปี ${THAI_YEAR} โปรดตรวจสอบรายละเอียดและดำเนินการตามกำหนดเวลา`},
${startDate},
${endDate},
${index < 4},
${status},
${ownerUserId},
${ownerUserId}
)
returning id, title, status
`;
announcements.push(announcement);
}
return announcements;
}
async function seedNotifications(sql, organizationId, ownerUserId, employees, trainingRecords, announcements) {
const approvedRecords = trainingRecords.filter((record) => record.status === "approved");
const rejectedRecords = trainingRecords.filter((record) => record.status === "rejected");
const notifications = [];
for (const record of approvedRecords.slice(0, 120)) {
notifications.push({
organizationId,
userId: record.userId,
title: `${SEED_PREFIX} รายการอบรมได้รับการอนุมัติ`,
message: `${record.courseName} ได้รับอนุมัติชั่วโมงอบรมแล้ว`,
type: "approved",
referenceType: "training_record",
referenceId: String(record.id),
isRead: random() < 0.55,
});
}
for (const record of rejectedRecords.slice(0, 40)) {
notifications.push({
organizationId,
userId: record.userId,
title: `${SEED_PREFIX} รายการอบรมไม่ผ่านการอนุมัติ`,
message: `${record.courseName} ต้องแก้ไขข้อมูลก่อนส่งใหม่`,
type: "rejected",
referenceType: "training_record",
referenceId: String(record.id),
isRead: random() < 0.45,
});
}
for (const announcement of announcements) {
const recipients = chunk(
employees.sort(() => random() - 0.5),
6
)[0] || [];
for (const employee of recipients) {
notifications.push({
organizationId,
userId: employee.id,
title: `${SEED_PREFIX} ประกาศใหม่`,
message: `${announcement.title} กรุณาเปิดอ่านรายละเอียด`,
type: "announcement",
referenceType: "announcement",
referenceId: String(announcement.id),
isRead: random() < 0.35,
});
}
}
for (let index = 0; index < 20; index += 1) {
notifications.push({
organizationId,
userId: index % 2 === 0 ? ownerUserId : pick(employees).id,
title: `${SEED_PREFIX} สรุปการนำเข้าพนักงาน`,
message: `ไฟล์ ${pick(IMPORT_TEMPLATE_NAMES)} ประมวลผลเสร็จสิ้น`,
type: "reminder",
referenceType: "import_job",
referenceId: null,
isRead: random() < 0.6,
});
}
for (let index = 0; index < 30; index += 1) {
notifications.push({
organizationId,
userId: ownerUserId,
title: `${SEED_PREFIX} ผลการทบทวนข้อมูลหลัก`,
message: `รายการข้อมูลหลักรอการติดตามผลลำดับที่ ${index + 1}`,
type: index % 2 === 0 ? "approved" : "rejected",
referenceType: "master_review",
referenceId: String(index + 1),
isRead: random() < 0.5,
});
}
while (notifications.length < 300) {
notifications.push({
organizationId,
userId: pick(employees).id,
title: `${SEED_PREFIX} แจ้งเตือนติดตามผลการทดสอบ`,
message: `กรุณาตรวจสอบข้อมูลการทดสอบรอบที่ ${notifications.length + 1}`,
type: "reminder",
referenceType: null,
referenceId: null,
isRead: random() < 0.5,
});
}
if (notifications.length > 300) {
notifications.length = 300;
}
for (const notification of notifications) {
await sql`
insert into notifications (
organization_id,
user_id,
title,
message,
type,
reference_type,
reference_id,
is_read,
read_at,
created_at
)
values (
${notification.organizationId},
${notification.userId},
${notification.title},
${notification.message},
${notification.type},
${notification.referenceType},
${notification.referenceId},
${notification.isRead},
${notification.isRead ? makeThaiDate(randomInt(0, 11), randomInt(1, 25)) : null},
${makeThaiDate(randomInt(0, 11), randomInt(1, 25))}
)
`;
}
return {
total: notifications.length,
unread: notifications.filter((item) => !item.isRead).length,
};
}
async function seedAuditLogs(sql, organizationId, ownerUserId, employees, trainingSummary, announcements) {
const auditEntries = [];
const moduleCatalog = [
["TRAINING_RECORDS", "CREATE"],
["TRAINING_RECORDS", "UPDATE"],
["HRD_REVIEW", "APPROVE"],
["HRD_REVIEW", "REJECT"],
["ANNOUNCEMENTS", "ANNOUNCEMENT_CREATE"],
["ANNOUNCEMENTS", "ANNOUNCEMENT_PUBLISH"],
["NOTIFICATIONS", "NOTIFICATION_CREATE"],
["EMPLOYEE_IMPORT", "IMPORT"],
["MASTER_REVIEW", "APPROVE"],
["MASTER_REVIEW", "REJECT"],
];
const topCourses = trainingSummary.courseSummary.map(([name]) => name);
for (let index = 0; index < 500; index += 1) {
const [moduleName, action] = moduleCatalog[index % moduleCatalog.length];
const actor =
index % 4 === 0 ? ownerUserId : pick(employees).id;
const entityName =
moduleName === "ANNOUNCEMENTS"
? `${SEED_PREFIX} ${pick(announcements).title}`
: moduleName === "TRAINING_RECORDS" || moduleName === "HRD_REVIEW"
? `${SEED_PREFIX} ${pick(topCourses)}`
: `${SEED_PREFIX} รายการทดสอบ ${index + 1}`;
auditEntries.push({
organizationId,
userId: actor,
moduleName,
action,
entityName,
entityId: String(index + 1),
oldValue:
action === "UPDATE" || action === "REJECT"
? { state: "before", marker: SEED_PREFIX, sequence: index + 1 }
: null,
newValue: { state: "after", marker: SEED_PREFIX, sequence: index + 1 },
ipAddress: `10.25.69.${(index % 200) + 1}`,
userAgent: "UAT Seed Script",
createdAt: makeThaiDate(randomInt(0, 11), randomInt(1, 25)),
});
}
for (const entry of auditEntries) {
await sql`
insert into audit_logs (
organization_id,
user_id,
module_name,
action,
entity_name,
entity_id,
old_value,
new_value,
ip_address,
user_agent,
created_at
)
values (
${entry.organizationId},
${entry.userId},
${entry.moduleName},
${entry.action},
${entry.entityName},
${entry.entityId},
${entry.oldValue},
${entry.newValue},
${entry.ipAddress},
${entry.userAgent},
${entry.createdAt}
)
`;
}
return auditEntries.length;
}
function buildReviewMarkdown(summary) {
const departmentRows = Object.entries(summary.departmentDistribution)
.map(([department, count]) => `- ${department}: ${count} คน`)
.join("\n");
const trainingDepartmentRows = Object.entries(summary.trainingRecordDistribution)
.map(([department, count]) => `- ${department}: ${count} รายการ`)
.join("\n");
const statusRows = Object.entries(summary.trainingStatusDistribution)
.map(([status, count]) => `- ${status}: ${count} รายการ`)
.join("\n");
const monthRows = Object.entries(summary.monthlyApprovedDistribution)
.sort(([left], [right]) => left.localeCompare(right))
.map(([month, count]) => `- ${month}: ${count} รายการอนุมัติ`)
.join("\n");
const categoryRows = Object.entries(summary.categoryHoursDistribution)
.map(([category, hours]) => `- ${category}: ${hours.toFixed(1)} ชั่วโมง`)
.join("\n");
const courseRows = summary.topCourses
.map(([courseName, hours]) => `- ${courseName}: ${hours.toFixed(1)} ชั่วโมง`)
.join("\n");
return `# UAT Seed Data Review
## 1. Records generated
- Organization: ${summary.organizationName} (\`${summary.organizationSlug}\`)
- HRD owner account: \`${summary.ownerEmail}\`
- Employee password: \`${summary.defaultPassword}\`
- Employees: ${summary.counts.employees}
- Courses: ${summary.counts.courses}
- Training Records: ${summary.counts.trainingRecords}
- Announcements: ${summary.counts.announcements}
- Notifications: ${summary.counts.notifications}
- Audit Logs: ${summary.counts.auditLogs}
- Training Policy Year: Thai ${THAI_YEAR} (stored as Gregorian ${GREGORIAN_YEAR})
## 2. Distribution summary
### Employee distribution by department
${departmentRows}
### Training record distribution by department
${trainingDepartmentRows}
### Training status distribution
${statusRows}
### Approved K/S/A hours distribution
${categoryRows}
### Monthly approved trend
${monthRows}
## 3. Dashboard validation
The generated dataset was shaped to support meaningful dashboard output:
- Department Ranking:
- multiple departments have approved-hour activity
- Engineering and Operations contain higher approved-hour volume
- Course Ranking:
- repeated course usage creates stable top-course results
- expected top courses:
${courseRows}
- K/S/A Distribution:
- approved records include mixed K/S/A category coverage
- Monthly Trend:
- approved records are spread across all 12 months of Thai year ${THAI_YEAR}
## 4. Report validation
The seed set supports report verification for:
- employee-level training history
- HRD organization-wide pending review visibility
- approved vs pending vs rejected comparison
- yearly filtering for Thai year ${THAI_YEAR}
- course-level and department-level aggregation
- notification center pagination and unread counts
- audit log filtering by module and action
## 5. Known limitations
- Thai year ${THAI_YEAR} is stored in the database as Gregorian year ${GREGORIAN_YEAR} because application queries use Gregorian year boundaries
- Seeded notifications and announcements include the ${SEED_PREFIX} marker for easy identification during UAT
- No certificate files are generated in this seed set
- This script seeds a dedicated UAT organization and does not try to merge into existing business organizations
`;
}
async function writeReview(summary) {
const target = path.join(process.cwd(), "docs", "uat-seed-data-review.md");
await fs.writeFile(target, buildReviewMarkdown(summary), "utf8");
}
async function main() {
if (!DEFAULT_CONNECTION_STRING) {
throw new Error("DATABASE_URL is required");
}
const sql = postgres(DEFAULT_CONNECTION_STRING, { prepare: false });
try {
const ownerUserId = await ensureOwner(sql);
const organizationId = await ensureOrganization(sql, ownerUserId);
await cleanupOrganization(sql, organizationId, ownerUserId);
const { departments, positions } = await seedDepartmentsAndPositions(sql, organizationId);
await seedTrainingPolicy(sql, organizationId);
const departmentMap = new Map(departments.map((row) => [row.name, row.id]));
const positionMap = new Map(positions.map((row) => [row.name, row.id]));
const employees = await seedEmployees(
sql,
organizationId,
ownerUserId,
departmentMap,
positionMap
);
const courses = await seedCourses(sql, organizationId);
const trainingSummary = await seedTrainingRecords(
sql,
organizationId,
ownerUserId,
employees,
courses
);
const announcements = await seedAnnouncements(sql, organizationId, ownerUserId);
const notificationSummary = await seedNotifications(
sql,
organizationId,
ownerUserId,
employees,
trainingSummary.trainingRecords,
announcements
);
const auditLogCount = await seedAuditLogs(
sql,
organizationId,
ownerUserId,
employees,
trainingSummary,
announcements
);
const summary = {
organizationName: ORG_NAME,
organizationSlug: ORG_SLUG,
ownerEmail: OWNER_EMAIL,
defaultPassword: EMPLOYEE_PASSWORD,
counts: {
employees: employees.length,
courses: courses.length,
trainingRecords: trainingSummary.trainingRecords.length,
announcements: announcements.length,
notifications: notificationSummary.total,
auditLogs: auditLogCount,
},
departmentDistribution: employees.reduce((accumulator, employee) => {
accumulator[employee.departmentName] =
(accumulator[employee.departmentName] || 0) + 1;
return accumulator;
}, {}),
trainingRecordDistribution: trainingSummary.departmentSummary,
trainingStatusDistribution: trainingSummary.statusSummary,
monthlyApprovedDistribution: trainingSummary.monthlyApprovedSummary,
categoryHoursDistribution: trainingSummary.categorySummary,
topCourses: trainingSummary.courseSummary,
};
await writeReview(summary);
console.log("UAT seed data created successfully.");
console.log(JSON.stringify(summary, null, 2));
} finally {
await sql.end({ timeout: 1 });
}
}
main().catch((error) => {
console.error("Failed to seed UAT data.", error);
process.exitCode = 1;
});