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