ข้ามไปที่เนื้อหา

S3 — สิ่งที่ต่างจาก demo contract (Changes vs Demo)

เทียบอะไรกับอะไร

Demo = mock ใน ar_and_crs/fe/src/data.jsx (11 entity, in-memory, string id เช่น CR-001, array field, enum เป็นป้ายไทย) → เป้าหมาย = schema PRC (27 ตาราง normalized) บน PSDBPRDDB · ดูรายละเอียด: schema.sql · Data Dictionary · Backend Implementation · ภาพรวม + Traceability · ทั้งหมดเป็น target design — ยังไม่ใช่ของจริงใน DB

ทำไม redesign จาก demo

  • data.jsx เป็น in-memory mock สำหรับ frontend prototype — ไม่ใช่ schema ฐานข้อมูล มันบอกแค่ รูปร่างข้อมูลที่ UI ต้องใช้ (de-facto contract) ไม่ได้บอกวิธีจัดเก็บแบบ relational
  • ปัญหาที่ยกขึ้น production ตรง ๆ ไม่ได้:
  • id เป็น string เชิงโดเมน (CR-001, PJ-2569-01, REG-2569-1801) — ปนความหมายธุรกิจกับ key, generate ที่ฝั่ง mock, ไม่มี running number ที่ DB การันตี
  • array ฝังในแถว (PROJECTS.courses[], MEMBERS.courses[], TRAINERS.specialties[]) — N:M ที่ query/join/นับไม่ได้
  • enum เป็นป้ายไทย ('อนุมัติแล้ว', 'เปิดรับสมัคร', 'ใช้งาน') — เปราะ (พิมพ์ผิด/เปลี่ยนคำ = พัง), join/index บนสตริงไทย, แปลภาษาไม่ได้
  • ไม่มี referential integrity เลยcourseId:'CR-001' เป็นแค่ string ไม่มีใครการันตีว่า course นั้นมีจริง
  • field cross-reference (Course.attached:3, Trainer.courses:6, Project.enrolled:72) เก็บเป็นตัวเลข snapshot ที่ mock ตั้งไว้ — ไม่มีแถวจริงรองรับ
  • เป้าหมายคือแปลง mock 11 entity → normalized relational MSSQL 27 ตาราง: surrogate PK, FK ครบ, enum เป็น CHECK/lookup, audit + soft-delete, append-only log สำหรับสิ่งที่ต้อง trace

การเปลี่ยนแปลงหลัก (Demo → Target)

1) Surrogate PK + business code แยกบทบาท

ทุกตารางใช้ int IDENTITY(1,1) เป็น PK (log = bigint IDENTITY) — DB เป็นคนการันตี running number ส่วน business code ที่ demo เคยใช้เป็น id จะถูกเก็บเป็นคอลัมน์ UNIQUE ต่างหาก เฉพาะตารางที่โดเมนต้องโชว์ให้คนอ่าน

demo id (string) target PK business code (UNIQUE)
CR-001 Course.CourseId int Course.CourseCode (HR-LDR-2569)
PJ-2569-01 Project.ProjectId int Project.ProjectCode
REG-2569-1801 Registration.RegistrationId int Registration.RegistrationNo
CT-2569-0001 Certificate.CertificateId int Certificate.CertificateNo
EQ-2566-001 Equipment.EquipmentId int Equipment.EquipmentCode (RLPD-PROJ-001)
TR-001,M-001,BCH-001,KN-…,EM-…,AU-… …Id int (ไม่มี business code — id เดิมเป็นแค่ surrogate ของ mock)

หมายเหตุ: demo Equipment.id (EQ-2566-001) กับ Equipment.code (RLPD-PROJ-001) แยกกันอยู่แล้ว — target เก็บเฉพาะ EquipmentCode ส่วน EQ-… ทิ้ง (เป็น mock id)

2) Array field → junction table

demo ฝัง N:M เป็น array ในแถว → target แตกเป็น junction table มี UNIQUE กันซ้ำ + FK สองด้าน

demo (array) target (junction) UNIQUE
PROJECTS.courses[] (['CR-002','CR-007']) ProjectCourse(ProjectId, CourseId) (ProjectId, CourseId)
MEMBERS.courses[] (['CR-001','CR-005']) Enrollment(MemberId, CourseId, BatchId) (MemberId, CourseId, BatchId)
TRAINERS.specialties[] (['สิทธิมนุษยชน','รัฐธรรมนูญ']) TrainerSpecialty(TrainerId, Specialty) (TrainerId, Specialty)

Enrollment ทำหน้าที่ทั้ง "ศิษย์เก่า/สมาชิกในหลักสูตร" + เพิ่มมิติ BatchId (รุ่น) ที่ demo array ไม่มี — รองรับ REQ-013

3) Enum strategy — lookup table vs CHECK constraint

แยกตามกฎ: lookup table เมื่อค่านั้น ถูกอ้างจาก ≥2 ตาราง หรือ มี attribute เพิ่ม หรือ แอดมินต้องแก้ได้เอง — ใช้ CHECK constraint เมื่อเป็น state-set คงที่ ผูกกับ logic ในโค้ด ไม่มี attribute

กลยุทธ์ ค่า/ฟิลด์ เหตุผล
Lookup table Province (77 จว.+MapX/Y heatmap), MemberType (5), KnowledgeType (5), OrgUnit (hierarchical), Role (6) ถูกอ้างหลายตาราง / มี attribute (พิกัด heatmap, ลำดับชั้นกอง) / แอดมินเพิ่ม-แก้ได้
CHECK constraint Course.Category/Level/Mode/Status, Registration.Status/Channel, Certificate.Result, EmailNotification.Status, KnowledgeReport.Status, Equipment.Status, Attachment.OwnerEntityType/Extension, AppUser.AuthSource, TrainerEditRequest.Status, Project.Status state-set ตายตัว ผูกกับ workflow/logic ในโค้ด ไม่มี attribute เพิ่ม — ไม่ควรให้แอดมินแก้รันไทม์ (จะทำ logic พัง)

4) Enum value mapping (ป้ายไทยใน demo → ASCII code ใน DB)

DB เก็บ ASCII code (join/index/แปลภาษาได้) ส่วนป้ายไทยเป็นเรื่องของ presentation layer

ฟิลด์ (target) ป้าย demo (ไทย/key) code (DB)
Registration.Status รออนุมัติ / อนุมัติแล้ว / ปฏิเสธ / รอแก้ไข PENDING / APPROVED / REJECTED / NEEDS_FIX
Registration.Channel webPortal / walkin / email / phone WEB_PORTAL / WALKIN / EMAIL / PHONE
Course.Mode onsite / online / hybrid ONSITE / ONLINE / HYBRID
Course.Level basic / inter / adv (ต้น/กลาง/สูง) BASIC / INTER / ADV
Course.Category executive / project EXECUTIVE / PROJECT
Course.Status ฉบับร่าง / เปิดรับสมัคร / ใกล้เต็ม / ปิดรับสมัคร / เผยแพร่ DRAFT / OPEN / NEAR_FULL / CLOSED / PUBLISHED
Equipment.Status ใช้งาน / ส่งคืน / ชำรุด / อยู่ระหว่างทดแทน ACTIVE / RETURNED / DAMAGED / REPLACING
Certificate.Result ผ่านเกณฑ์ / ไม่ผ่านเกณฑ์ PASS / FAIL
KnowledgeReport.Status รอแก้ไข / อนุมัติแล้ว (+ PENDING รอพิจารณา) NEEDS_FIX / APPROVED / PENDING
EmailNotification.Status sent / failed / bounced (+ pending ยังไม่ส่ง) SENT / FAILED / BOUNCED / PENDING

demo Course.draft:true/false (bit) เก็บคู่กับ status — target แยกเป็น IsDraft bit + Status ตาม presentation; เผยแพร่=PUBLISHED (เผยแพร่สาธารณะ) ต่างจาก เปิดรับสมัคร=OPEN (เปิดรับลงทะเบียน)

5) FK + referential integrity (demo มี 0 FK)

demo อ้างกันด้วย string (courseId:'CR-001', trainer:'TR-001', regId:'REG-…') ไม่มีใครการันตี — target ใส่ FK ครบทุกความสัมพันธ์ (ดู schema.sql SECTION 2) เช่น

  • Course.TrainerId → Trainer, Course.ProvinceId → Province
  • Registration.CourseId → Course, Registration.MemberTypeId → MemberType
  • Certificate.CourseId/MemberId/BatchId, Batch.CourseId, Enrollment.MemberId/CourseId/BatchId
  • EmailNotification.RegistrationId, EmailAttempt.EmailNotificationId
  • audit cols ทุกตาราง: CreatedBy/UpdatedBy → AppUser

6) Audit columns + soft-delete

ทุกตาราง business เพิ่ม CreatedAt/CreatedBy/UpdatedAt/UpdatedBy (demo ไม่มีเลย; เก็บแค่ joined/since/submitted/date เป็นวันที่เชิงธุรกิจ) — IsDeleted bit DEFAULT 0 เฉพาะที่ SRS ต้องการ:

มี IsDeleted เหตุผล
Member REQ-PRC-002 บังคับ soft-delete สมาชิก
Project REQ-PRC-004 ลบ 2 จังหวะ (soft แล้วยืนยัน)
Course, Trainer, Batch, Enrollment, Certificate, Equipment, KnowledgeReport, Attachment business record ที่ต้องกู้คืน/เก็บประวัติได้
ไม่มี ใน lookup (Province/MemberType/…) ใช้ IsActive แทน
ไม่มี ใน append-only log (*PrintLog/*Attempt/*History/AuditLog) ห้ามแก้/ลบ ตามนิยาม

7) Single polymorphic Attachment table

demo เก็บไฟล์แนบเป็น ตัวเลขนับ Course.attached:3 (ไม่มีไฟล์จริง) — target ทำเป็น ตารางเดียว polymorphic Attachment(OwnerEntityType, OwnerEntityId, …) รองรับเจ้าของ 6 ชนิด (COURSE/PROJECT/TRAINER/REGISTRATION/KNOWLEDGE_REPORT/MEMBER)

  • ทำไมตารางเดียว: metadata เหมือนกันทุกชนิดเจ้าของ (FileName/MimeType/Extension/FileSize/Sha256/StoragePath), ใช้ upload service + validation path เดียว (ตรวจ ≤50MB, นามสกุล 7 ชนิด CK_PRC_Attachment_Extension), ไม่ต้องมี CourseAttachment/ProjectAttachment/… ซ้ำ ๆ
  • trade-off: ไม่มี DB-level FK บน owner (column polymorphic ชี้ได้หลายตาราง) → บังคับ integrity ที่ service layer + IX_PRC_Attachment_Owner(OwnerEntityType, OwnerEntityId) (composite index) เพื่อ lookup/นับ
  • demo Course.attached (count) → กลายเป็น derived SELECT COUNT(*) FROM Attachment WHERE OwnerEntityType='COURSE' AND OwnerEntityId=? AND IsDeleted=0

8) Append-only log tables (เพิ่มใหม่ — demo ไม่มี/มีแค่ flat array)

ตารางที่ "ต้องเก็บทุกครั้งที่เกิด ห้ามแก้ย้อนหลัง" แยกออกเป็น log (PK bigint IDENTITY, ไม่มี Updated*/IsDeleted)

log table บันทึกอะไร demo เดิม
CertificatePrintLog ทุกครั้งที่พิมพ์ใบ (ORIGINAL/COPY + HasWatermark) — REQ-014 มีแค่ reprints:N (ตัวนับ)
EquipmentOwnershipHistory ทุกครั้งที่เปลี่ยนผู้รับผิดชอบครุภัณฑ์ — REQ-005 ไม่มี (demo เก็บแค่ responsible ปัจจุบัน)
EmailAttempt ทุกครั้งที่พยายามส่งเมล (AttemptNo, error) — REQ-010 EMAIL_LOG flat 1 แถว/เมล + retries:N
AuditLog กิจกรรมทั้งระบบ (denormalized ActorName/ActorRole/EntityType/EntityId) — REQ-014 AUDIT_LOG flat array (actor/role/action/target/at)

9) Denormalized counters เก็บไว้ (eventually-consistent display field)

demo เก็บตัวเลขสรุปในแถว (เพราะ mock ตั้งค่าไว้ตรง ๆ) — target เก็บไว้เป็น display field แต่ระบุชัดว่า source of truth = แถวสัมพันธ์ (service/trigger recompute, อาจ stale ชั่วคราว)

counter (target) demo source of truth จริง
Course.EnrolledCount enrolled:72 COUNT ของ Enrollment/Registration(APPROVED)
Project.Spent, Project.EnrolledCount spent, enrolled ยอดเบิกจ่ายจริง / รวม ProjectCourse → Enrollment
Trainer.TotalHours, Trainer.CourseCount hours, courses SUM(Course.Hours) / COUNT(Course) ที่สอน
Batch.CompletedCount completed COUNT ผู้ผ่านเกณฑ์ใน Certificate
Registration.EmailSent emailSent สถานะล่าสุดใน EmailNotification

Deviations จาก design_p2

# Decision design_p2 (S8/S9) S3 ทำต่าง เหตุผล
D7 PK = uniqueidentifier (GUID) PK = int IDENTITY(1,1) (log = bigint IDENTITY) S3 เป็น greenfield — ไม่มี legacy GUID ต้อง carry over; int running number เรียบง่าย, index-friendly, ตรงกับ demo ที่เป็น id เลขลำดับ
D6 CreatedBy/UpdatedBy = nvarchar (free text เก็บชื่อ/username) CreatedBy/UpdatedBy = int FK → PRC.AppUser S3 มี local AppUser mirror (thin mirror จาก SSO) อยู่แล้ว → ผูก FK ได้จริง, join หาโปรไฟล์ผู้กระทำได้ ไม่ต้องเก็บสตริงซ้ำ

ส่วนที่ เหมือน design_p2: schema namespace (PRC คู่กับ WEB/PORTAL), PascalCase ทุก identifier, audit cols มาตรฐาน, FK/CHECK/IX naming (FK_PRC_*/CK_PRC_*/IX_PRC_*), filtered index WHERE IsDeleted=0, auth แบบ thin mirror (ไม่เก็บ password — identity จาก SSO กลางผ่าน JWT)

Cross-cutting (วิธีจัดการที่กระจายทุกตาราง)

  • วันที่/เวลา: เก็บ datetime2 (UTC, SYSUTCDATETIME()) สำหรับ timestamp และ date (Gregorian/ค.ศ.) สำหรับวันเชิงธุรกิจ — แปลง พ.ศ. ที่ app layer (demo toBE()/fmtDateTh() ทำฝั่ง UI อยู่แล้ว) · ข้อยกเว้นเดียวที่เก็บ พ.ศ. = Batch.YearBE int (เป็น label รุ่นตาม demo year:2568 ไม่ใช่วันที่คำนวณ)
  • Cert watermark (REQ-014): พิมพ์ครั้งแรก → CertificatePrintLog(PrintType='ORIGINAL', HasWatermark=0), set Certificate.IsOriginalIssued=1 · พิมพ์ซ้ำ → ReprintCount++ + CertificatePrintLog(PrintType='COPY', HasWatermark=1) (ลายน้ำ "สำเนา") + AuditLog · demo original:bool+reprints:N แตกเป็น flag + log
  • Equipment 5 ปี (REQ-005): คำนวณ runtime DATEDIFF(YEAR, AcquiredAt, SYSUTCDATETIME()) >= 5 — ไม่เก็บ flag กันค่า stale (demo คำนวณจาก since ฝั่ง UI เช่นกัน)
  • Email retry/backoff (REQ-010): EmailNotification.RetryCount/MaxRetry(=3, CK 0..3)/NextRetryAt (exponential backoff คำนวณที่ app) · worker เลือกแถว Status='FAILED' AND RetryCount<MaxRetry AND NextRetryAt<=now ส่งซ้ำ แล้วบันทึก EmailAttempt ทุกครั้ง

หมายเหตุการนำไปใช้

  • demo เป็น mock ไม่มีข้อมูลจริง → ไม่ต้องทำ ETL/migrate (ต่างจาก S8/S9 ที่มี legacy DB) — เริ่ม seed lookup (Province 77, MemberType 5, KnowledgeType 5, Role 6, OrgUnit) แล้วสร้างใหม่
  • ต้อง provision ผู้ใช้เข้า SSO กลาง + auto-provision AppUser ตอน login ครั้งแรก (ExternalSubject จาก JWT sub)
  • ยืนยันจาก กสส. แล้ว (ดู index.md §การยืนยันจาก กสส.): IdP = AD + ThaID + Keycloak realm rlpd (AppUser.AuthSource รองรับ) · Per-Head = (CourseCost+AdminCost)/HeadCount (ไม่มีก้อนแยก) · retention log = 1 ปี · OrgUnit = มี master (คง FK, OrgText ไว้รองรับหน่วยงานนอก) · Score = กรอกมือ (ไม่มี post-test)
  • เพิ่มตาราง CoursePrerequisite (27→28 ตาราง) — กสส. ยืนยันว่า มี เงื่อนไขหลักสูตรก่อนสมัคร (self-ref CourseCourse, REQ-008) ; รับสมัครไม่จำกัดจำนวน (capacity คง TargetCount soft)
  • HR / legacy humanright_file = เลื่อน next-phase (ระบุใน SRS แต่เป็น "ระยะถัดไป/ถ้านำมาใช้") — v1 ไม่กระทบ schema