การพัฒนาระบบข้อมูล ATK
พุธ, 09 มีนาคม 2022

การพัฒนาระบบข้อมูล ATK

ที่มาของการพัฒนา

เกิดจากช่วงเดือนกันยายน 2021 ได้มีโครงการที่จะทำการ Surveillance ATK ของเจ้าหน้าที่โรงพยาบาลราชบุรี ทางกลุ่มงานที่ผมทำงานคือกลุ่มงานอาชีวเวชกรรม จึงได้รับหน้าที่ให้มาออกแบบการเฝ้าระวัง ซึ่งก็ได้จัดกลุ่มความเสี่ยงเป็นสามแบบคือ

  • เสี่ยงสูง ตรวจเดือนละ 4 ครั้ง
  • เสี่ยงกลาง ตรวจเดือนละ 2 ครั้ง
  • เสี่ยงต่ำ ตรวจเดือนละ 1 ครั้ง

จึงต้องทำระบบรายงานผลการตรวจ ATK ซึ่งในตอนแรกเลือกใช้ Google Form เนื่องจากทำง่าย และได้ข่าวว่าทาง IT จะทำระบบที่มันดีกว่านี้มาแทนอยู่แล้ว

ภาพ Google Form ที่ใช้เก็บข้อมูล

ปัญหาที่เจอหลังการใช้ Google Form ในการเก็บข้อมูล

เนื่องจาก Google form นั้นไม่สามารถตั้งกฎเกณฑ์ในการ Validate ข้อมูลได้มากนัก เช่น เลขประจำตัว 13 หลัก ผมทำได้แค่ Regular expression ^\d13$ แต่ไม่สามารถคำนวนเพื่อตรวจสอบกับเลขหลักหน่วยได้ และไม่สามารถสอบวันที่กรอกได้ ทำให้มีทั้งคนกรอกปี พ.ศ บ้าง คศ. บ้าง หรือวันเกิด มาในช่องวันตรวจ

นอกจากนี้ คนที่กรอกข้อมูลยังต้องกรอกข้อมูลเดิมซ้ำๆ เช่น ชื่อ นามสกุล เลขบัตรประชาชน เบอร์โทรศัพท์ กลุ่มงาน แผนก ที่ทำงาน ทุกครั้งที่ส่งข้อมูลผ่าน Google form ซึ่งก็เพิ่มความลำบากในการกรอก กรอกข้อมูลไม่ตรงกันในแต่ละครั้ง เช่น ชื่อหน่วยงานที่ทำ และกรอกข้อมูลผิด เช่น กรอกเลขบัตรประชาชนบางหลักผิดไป (ตอนนั้นให้กรอก เพราะเพื่อเป็นการเช็คข้อมูลด้วย และไม่สะดวกที่จะมา Merge กับฐานข้อมูลรายชื่อว่าคนนี้เป็นใคร) ทำให้เวลาเอามานับว่าคนนี้ตรวจกี่ครั้งแล้วผิดพลาด เพราะเลขไม่ตรงกัน อีกทั้งเนื่องจากไม่ได้บังคับว่าต้อง Sign in Google Account ก่อน (เพราะบางคนไม่มี และยิ่งยากต่อการกรอก) ทำให้ไม่สามารถแก้ไขได้หลัง submit เกิดการกรอกข้อมูลซ้ำๆ มารัวๆ

ข้อมูลที่ได้จาก Google Form ในรูปของ Google Sheet

เมื่อจะนำข้อมูลไปวิเคราะห์สถิติเชิงพรรณาว่าแบบแต่ละแผนกตรวจครบหรือไม่ ยังขาดใครจึงต้อง Clean ข้อมูลอย่างยากลำบาก แม้ว่าจะใช้ Pandas ช่วยแล้วก็ตาม ยังมีบางส่วนที่จำเป็นต้อง Clean ด้วยมือ อีกทั้งรายชื่อเจ้าหน้าที่ได้จาก HR ก็ดูเหมือนจะมีจำนวนเจ้าห้นาที่มากกว่าความจริง และไม่ได้รวมพวก Outsource หรือ นักศึกษาแพทย์อย่างครบถ้วน

หลังจากทนมาหลายเดือน ที่วิเคราะห์ข้อมูลอย่างยากลำบาก และยังไม่มีระบบอื่นมาทดแทน Google Form จึงตัดสินใจพัฒนาเองในช่วงปลายเดือนกุมภาพันธ์ 2022

ปํญหาที่สำคัญคือไม่มีรายชื่อเจ้าหน้าที่ที่ตรวจ ATK ที่ถูกต้อง

ไม่น่าเชื่อว่านี้เป็นปัญหา แต่มันเป็นปัญหา คือ รายชื่อที่ได้จาก HR นั้นจะไม่มีเจ้าหน้าที่ Outsource หรือจ้างเหมา เช่น แม่บ้านเอกชน เจ้าหน้าที่ห้อง CT เป็นต้น และเหมือนมันไม่ได้ Update แบบล่าสุดคือ เหมือนมันยังมีรายชื่อคนที่ไม่ได้อยู่แล้วอยู่ในรายชื่อ ทำให้แผนจากในตอนแรกที่จะเอารายชื่อนี้เป็นฐานข้อมูล เพื่อที่จะได้บอกว่า กลุ่มงานและแผนก นี้มีกี่คน ตรวจกี่คน ครบไหม เป็นไปไม่ได้ ผมจึงต้องเอารายการตอบจาก Google Form ตั้งแต่เดือนกันยายน 2021 - กุมภาพันธ์ 2022 จำนวนประมาณ 14900 ครั้ง มาทำการประมวลผล ซึ่งจะมีปัญหาที่สำคัญ ที่ผมบอกไปคือ ข้อมูลมันกรอกเข้ามาผิด

ปํญหาในการวิเคราะห์ข้อมูลจาก Google Form

ข้อมูลที่ได้มันต้องมาทำความสะอาดอย่างเยอะ และค่อนข้าง Manual เสียเวลา

การวิเคราะห์ข้อมูล

การเตรียมข้อมูล

ทำความสะอาดข้อมูลวันที่ตรวจ

ข้อมูลที่กรอกเข้ามามีความผิดพลาด เช่น ใส่วันเกิดในวันที่ตรวจ ใส่เดือนและปีผิด (คือใส่ไปในอดีตที่ก่อนหน้าโครงงานตรวจหรือใส่ไปในอนาคต) หรือใส่สลับไปมาหระหว่าง คส และ พศ ทำให้มีความจำเป็นต้องการทำความสะอาดข้อมูลโดยผมจะตรวจสอบว่าวันตรวจว่าอยู่ในช่วงเวลาที่ถูกต้องไหมหากไม่ถูกต้องจะ Timestamp มาใช้แทน

เตรียมข้อมูลรายชื่อกลุ่มงาน (Department)

หลักการคือผมต้องเตรียม Table ของแต่ละประเภทของ Entity แยกจากกัน และแต่ละ Entity จะมีความสัมพันธ์กันด้วย Primary และ Foreign Key เพื่อที่จะได้สามารถนำข้อมูลเข้าไปยัง Relational database ได้ ดังนั้นผมจะใช้ Pandas ซึ่งเป็น Library บน Python 3 ผ่าน Jupyter บน Visual Studio Code ในการเตรียมข้อมูล นำเข้าไฟล์ HR โดยใช้ pd.read_excel("HR.xlsx") ก่อนจากนั้น drop_duplicate(subset=[“Department”]) รายชื่อที่ได้จาก HR เพื่อให้ Dataframe เหลือแต่รายชื่อกลุ่มงานที่ไม่ซ้ำกัน แล้วสร้าง Primary Key ด้วย UUID4 แล้วส่งออกข้อมูลเป็น JSON โดยใช่ pd.to_json("departments.json",orients="records")

การเตรียมรายชื่อกลุ่มงาน

เตรียมข้อมูลรายชื่อแผนก (Division)

ทำแบบเดียวกันนี้กับแผนก โดย drop_duplicate(subset=[“Department”,”Division”]) เพื่อสร้าง Dataframe ที่มีรายชื่อแผนกที่ไม่ซ้ำกัน แล้วสร้าง Primary Key เช่นเดียวกัน และใช้ df_divisions.merge(df_departments.rename(columns={"Id":"DepartmentId"}),how="left",on="กลุ่มงาน") (join วิธีไหนก็ไม่ต่างกันในกรณีนี้) กับ departments เพื่อสร้าง Foreign key (ซึ่งก็คือ DepartmentId) แล้ว pd.to_json() เป็น divisions.json

การเตรียมรายชื่อแผนก

เตรียมข้อมูลรายชื่อคนตรวจ (Person)

ผมจะยึดข้อมูลที่ตอบมาทาง Google form เป็นหลัก (แปลว่าถ้าใครไม่เคยตอบจะไม่มีชื่อ และผมรายการตอบใน Google form ว่า Entry เพราะการตอบใน Google form 14900 ครั้ง คือการรายงานผล ATK ในแต่ละครั้งซึ่งแน่นอนว่าชื่อคนย่อมต้องซ้ำกัน และคนที่ไม่เคยตอบเลยในช่วง 5 เดือน ก็ถือว่าอาจจะไม่อยู่แล้ว แต่ผมมีระบบ register ภายหลัง) เพราะว่าข้อมูลจาก HR มันไม่มี Outsource และมันยังไม่ได้ตัดคนที่ไปเรียนต่อ ลาออก หรือ ตาย บางส่วนออกไป โดยการทำการ left join ข้อมูลกับไฟล์ HR (เนื่องจากผมเชื่อถือ Personal data จาก HR มากกว่า Google form) โดยเริ่มจาก CitizenID ก่อน เป็น df_people_1 และเอา Entry ที่ไม่ matched มา left join ด้วยชื่อและนามสกุลพร้อมกัน ก็จะได้เป็น df_people_2 แล้วเอาเฉพาะคนที่ matched จากทั้งสอง Dataframe มารวมกัน pd.concat([df_people_1,df_people_2]).query("_merge=='both'")

ที่นี้จะเหลือแต่คนที่ไม่ matched อะไรเลย ตรงนี้ผมและทีมงานจำเป็นต้องตรวจสอบและแก้ไขด้วยมือซึ่งมีประมาณ 200 กว่า Entry จากนั้น จึงนำเข้าไปรวมกับรายชื่อที่ได้ในตอนแรก

การรวมรายชื่อที่ทำความสะอาดแล้ว

เมื่อรวมรายชื่อแล้ว ก็สร้าง Primary Key ด้วย UUID4 เหมือนเดิม

การเตรียมรายชื่อคน

เตรียมข้อมูลชนิดของ ATK

ใช้หลักการเดิมคือ drop_duplicate ยี่ห้อ ATK ที่ตรวจ แต่พบปัญหาคือข้อมูลยี่ห้อ ATK ที่ตรวจ มันมีถึง 200 แบบ ซึ่งเกิดจากการพิมพ์ยี่ห้อที่ใช้ดรวจมันไม่ consistency เลย ผมจึงต้องแก้ไข้โดย

  1. Replace ชื่อยี่ห้อการตรวจในของ Entries 14900 ครั้ง โดยดูจาก string 200 แบบว่ามันมี pattern อะไรบ้าง และมันเป็นของยี่ห้อไหน แล้วใช้ Regular expression ในการ Replace
  2. อันที่ดูไม่รู้เรื่อง ก็ใช้ข้อมูลช่วงวันการตรวจว่า เป็นช่วงที่แจก ATK ยี่ห้อไหนแทน
ทำความสะอาดรายชื่อ ATK

เมื่อทำแล้วจะเหลือ ATK อยู่ไม่กี่ยี่ห้อ แล้วจึง drop_duplicate แล้วสร้าง Primary Key ด้วย UUID4 เช่นเดิม แล้ว export เป็น atk.json

การเตรียมรายชื่อ ATK

เตรียมข้อมูลรายการตรวจ (Entry)

ตรงนี้ก็จะง่ายขึ้นแล้ว เพราะส่วนยากของการเตรียมข้อมูลได้ทำไปหมดแล้ว ตรงนี้เหลือแค่สร้าง Primary Key ด้วย UUID4 จากนั้นตัด columns ที่ไม่จำเป็นออกไป (เพราะเราจะ inner join กับ table อื่น) แล้ว inner join (เพราะลอง left join แล้วพบว่าข้อมูลมันตรงกันหมด แน่หล่ะ มันมาจากแหล่งข้อมูลเดียวกัน) กับ atk และ people เพื่อให้ได้ foregin key AntigenTestKitId และ PersonId

การเตรียมรายการตรวจ

พัฒนาโปรแกรม

เลือกเครื่องมือที่ใช้ในการพัฒนา

ในส่วนของ Database server นั้นใช้ PostgreSQL และส่วนของ Web application นั้นส่วน FrontEnd ใช้ Blazor Webassembly ซึ่งมีข้อดีอย่างมากสำหรับนักพัฒนาคนเดียว คือมันใช้ภาษา C# เขียน จึงสามารถแชร์ Code บางส่วนร่วมกับส่วน BackEnd ที่ใช้ Asp.Net Core ในการเขียน

FrontEnd : Blazor Webassembly

ก็คล้ายๆ กับ React แต่แทนที่จะเป็น Javascript กลายเป็น C# แทน (แต่ยังสามารถใช้ Javascript และใช้ Javascript Interop ได้) ตัว Blazor componenet (ไฟล์ .razor แบบเดียวกันกับ Razor page) จะผสมกันระหว่าง HTML CSS Javascript และ C#

@page "/register"
@layout IndexLayout
@using ATKSurvey.Client.Models
@using ATKSurvey.Client.Services
@using ATKSurvey.Shared.Dto.Account
@inject DepartmentService departmentService
@inject DivisionService divisionService
@inject AccountService accountService
@inject IStringLocalizer<RegisterPage> Loc
@inject NavigationManager nav
<PageHeader Messages=Messages>
<ChildContent>
<h1>@Loc["Register new account"]</h1>
</ChildContent>
<Controls>
<Button SymbolIcon=SymbolIcon.CloudUpload Type="submit" form="reg-form"
ButtonType=CardButtonType.Primary>@Loc["Register"]</Button>
</Controls>
</PageHeader>
<LoadingView IsReady=IsReady>
<EditForm id="reg-form" Model=RegDto OnValidSubmit=OnValidSubmitHandler>
<DataAnnotationsValidator />
<h2>@Loc["Account information"]</h2>
<InputTextEx Label=@Loc["Username"] @bind-Value=RegDto.UserName />
<InputTextEx type="password" Label=@Loc["Password"] @bind-Value=RegDto.Password />
<InputTextEx type="password" Label=@Loc["Confirmed password"] @bind-Value=RegDto.ConfirmedPassword />
<h2>@Loc["Basic information"]</h2>
<InputSelectEx @bind-Value=RegDto.Title Label="คำนำหน้า">
<option value=@Title.Mr>นาย</option>
<option value=@Title.Miss>นางสาว</option>
<option value=@Title.Mrs>นาง</option>
</InputSelectEx>
<InputTextEx @bind-Value=RegDto.Name Label=@Loc["Name"] />
<InputTextEx @bind-Value=RegDto.Surname Label=@Loc["Surname"] />
..omitted..
</EditForm>
</LoadingView>
@code{
private bool IsReady;
private List<UIMessage> Messages = new List<UIMessage>();
private List<Department> Departments = new List<Department>();
private List<Division> Divisions = new List<Division>();
private RegistrationDTO RegDto = new RegistrationDTO();
[CascadingParameter]
private Task<AuthenticationState> authTask { get; set; }
protected override async Task OnInitializedAsync()
{
if ((await authTask).User.Identity.IsAuthenticated)
{
nav.NavigateTo("/");
}
else
{
var deT = departmentService.GetPublic();
var diT = divisionService.GetPublic();
await Task.WhenAll(deT, diT);
Departments.AddRange(deT.Result);
Divisions.AddRange(diT.Result);
IsReady = true;
await base.OnInitializedAsync();
}
}
..omitted..
}

ตัวอย่าง ฺBlazor บางส่วนของหน้าสมัครผู้ใช้งาน

ส่วนของ CSS นั้น ผมไม่เขียนเองโดยตรงทั้งหมดแต่ใช้ Tailwind CSS 3 ช่วย มันทำให้งานง่ายตรงที่เราสามารถใช้ Utility class ทั้งหมด ใส่ลงไปใน ได้เลย โดยไม่ต้องสลับไปมาระหว่าง CSS และมันสามารถ Trim ตัว CSS ให้เหลือเฉพาะที่เราใช้จริงๆ ได้ และถามว่า class จะ รกไปหมดไหม ก็ตอบว่ามันแก้ได้โดยการที่เรา สร้าง Blazor component ขึ้นมาใหม่ หรือเรารวมเป็น class เดียวกันได้แบบนี้

@layer componenets {
.my-component {
@apply rounded-xl shadow-xl flex flex-row gap-6 flex-wrap;
}
}

ตัวอย่างการใช้งาน Tailwind CSS

BackEnd : ASP.Net Core

โดยการสร้าง table ต่างๆ ในฐานข้อมูลนั้น จะไม่ได้สร้างโดยตรง แต่ใช้ผ่าน Entity framework core ซึ่งเป็น ORM Mapper โดยเราเพียงสร้าง model (ก็คือ C# Class) ที่เราจะใช้เก็บข้อมูลต่างๆ และระบุความสัมพันธ์โดยใช้ convention, attribute, และ EF Core Fluent API จากนั้น EF Core จะทำการ generate SQL ที่จำเป็นเพื่อไปสั่ง PostgreSQL สร้างฐานข้อมูลและ table ทีเ่กี่ยวข้อง นอกจากนี้ยังสามารถ track entity ได้ด้วย ซึ่งก็คือ เวลาเราเปลี่ยน public proterty ของ instance ของ C# class นั้น ก็จะรู้ได้ว่า property ไหนเปลี่ยนและ generate SQL ที่จำเป็นในการอัพเดตเมื่อ await context.SaveChangeAsync()

Features ของโปรแกรม

สถิติ + อัพเดตข้อมูลทุก ๆ 1 นาที เมื่อจำเป็น + ส่งสัญญาณการอัพเดตข้อมูลมายัง Client ด้วย SignalR

สถิติภาพรวม
สถิติรายกลุ่มงาน
สถิติรายแผนก
สถิติรายวัน สำหรับ Admin

ระบบจะคำนวนสถิติเองทุกๆ 1 นาที เมื่อมีคนสส่งข้อมูล (และไม่คำนวนถ้าไม่มีข้อมูลใหม่) และเม่อคำนวนเสร็จจะแจ้ง Client ว่ามีข้อมูลใหม่ให้อัพเดตอัตโนมัติ

var globalStats = entries.GroupBy(e => e.DepartmentId).Select(entryGroupedByDepartment => new GlobalDepartmentStatistic(
entryGroupedByDepartment.Key,
entryGroupedByDepartment.First().DepartmentName,
entryGroupedByDepartment.GroupBy(e => e.DivisionId)
.Select(entryGroupByDivision => new GlobalDivisionStatistic(
entryGroupByDivision.First().DivisionId,
entryGroupByDivision.First().DivisionName,
entryGroupByDivision.First().RiskGroup,
entryGroupByDivision.GroupBy(e => e.PersonId).Count(),
entryGroupByDivision
.GroupBy(entry => new TestMonthYear(entry.TestDate.Month, entry.TestDate.Year))
.Select(entriesGroupedByDate => new DivisionTestCompletenessPerMonthYear(
entriesGroupedByDate.Key,
entriesGroupedByDate.GroupBy(k => k.PersonId).Count(l => l.Count() >= entryGroupByDivision.First().DivisionTestsPerMonth),
entriesGroupedByDate.GroupBy(k => k.PersonId).Count(l => l.Count() < entryGroupByDivision.First().DivisionTestsPerMonth)
)).ToArray()
)).ToArray()
)).ToArray();
lock (lockObject)
{
CurrentGlobalStatisticSummary = new GlobalStatisticSummary(DateTimeOffset.Now, allTestsCount, allPositivesCount, thisMonthTestsCount, thisMonthPositivesCount, todayTestsCount, todayPositivesCount);
CurrentGlobalStatisticDetail = new GlobalStatisticDetail(CurrentGlobalStatisticSummary, globalStats);
CurrentDepartmentsStatistic = new DepartmentsStatistic(DateTimeOffset.Now, b);
}

บางส่วนของการคำนวน

กรอกฟอร์มและดูประวัติตนเอง และแก้ไขได้

กรอกข้อมูลง่านยเพราะไม่ต้่องกรอกใหม่หมดทุกอย่าง และสามารถกลับมาแก้ไขได้

กรอกฟอร์ม
ประวัติตนเอง

สมัครผู้ใช้งานใหม่เพิ่มเติมเองได้

ถึงแม้ว่าผมจะสร้างบัญชีผู้ใช้ให้แบบอัตโนมัติ 2400 บัญชี แต่อย่างที่บอกถ้าไม่เคยตอบ Google form เลย ก็จะไม่มีรายชื่อ จึงต้องมีทำให้ผู้ใช้สามารถสมัครบัญชีเพิ่มเองได้

ติดตามได้ที่
© 2016 - 2022 สงวนลิขสิทธิ์ สุทธิศักดิ์ เด่นดวงใจ
Made with Gatsby Developed with Visual Studio Code