1407 lines
45 KiB
JavaScript
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;
|
|
});
|