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 → ProvinceRegistration.CourseId → Course,Registration.MemberTypeId → MemberTypeCertificate.CourseId/MemberId/BatchId,Batch.CourseId,Enrollment.MemberId/CourseId/BatchIdEmailNotification.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) → กลายเป็น derivedSELECT 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 indexWHERE IsDeleted=0, auth แบบ thin mirror (ไม่เก็บ password — identity จาก SSO กลางผ่าน JWT)
Cross-cutting (วิธีจัดการที่กระจายทุกตาราง)¶
- วันที่/เวลา: เก็บ
datetime2(UTC,SYSUTCDATETIME()) สำหรับ timestamp และdate(Gregorian/ค.ศ.) สำหรับวันเชิงธุรกิจ — แปลง พ.ศ. ที่ app layer (demotoBE()/fmtDateTh()ทำฝั่ง UI อยู่แล้ว) · ข้อยกเว้นเดียวที่เก็บ พ.ศ. =Batch.YearBE int(เป็น label รุ่นตาม demoyear:2568ไม่ใช่วันที่คำนวณ) - Cert watermark (REQ-014): พิมพ์ครั้งแรก →
CertificatePrintLog(PrintType='ORIGINAL', HasWatermark=0), setCertificate.IsOriginalIssued=1· พิมพ์ซ้ำ →ReprintCount+++CertificatePrintLog(PrintType='COPY', HasWatermark=1)(ลายน้ำ "สำเนา") +AuditLog· demooriginal: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,CK0..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จาก JWTsub) - ยืนยันจาก กสส. แล้ว (ดู 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-refCourse→Course, REQ-008) ; รับสมัครไม่จำกัดจำนวน (capacity คงTargetCountsoft) - HR / legacy
humanright_file= เลื่อน next-phase (ระบุใน SRS แต่เป็น "ระยะถัดไป/ถ้านำมาใช้") — v1 ไม่กระทบ schema