251202:1300

This commit is contained in:
2025-12-02 13:26:05 +07:00
parent d62beaa1bd
commit 5acc631994
5 changed files with 3024 additions and 1158 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -73,17 +73,24 @@
"editor.defaultFormatter": "dbaeumer.vscode-eslint" "editor.defaultFormatter": "dbaeumer.vscode-eslint"
}, },
"[sql]": { "[sql]": {
"editor.defaultFormatter": "mtxr.sqltools",
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.insertSpaces": true "editor.insertSpaces": true
}, },
"sqltools.codelensLanguages": ["sql", ""], "sqltools.codelensLanguages": ["sql"],
"sqltools.format": { "sqltools.format": {
"language": "sql", "language": "sql",
"params": { "params": {
"keywordCase": "upper", // ทำให้ INSERT, VALUES เป็นตัวใหญ่ "keywordCase": "upper", // ทำให้ INSERT, VALUES เป็นตัวใหญ่
"tabWidth": 2, // เยื้อง 3 ช่องว่าง (ปรับจาก 4 เป็น 3 เพื่อ match ตัวอย่าง) "tabWidth": 2, // เยื้อง 3 ช่องว่าง (ปรับจาก 4 เป็น 3 เพื่อ match ตัวอย่าง)
"expressionWidth": 80 // ความยาวสูงสุดต่อบรรทัดก่อนตีบรรทัดใหม่ (ป้องกันคอลัมน์แยกบรรทัด) "expressionWidth": 80, // ความยาวสูงสุดต่อบรรทัดก่อนตีบรรทัดใหม่ (ป้องกันคอลัมน์แยกบรรทัด)
"reservedWordCase": "upper", // ทำให้คำสงวนเป็นตัวใหญ่
"linesBetweenQueries": 1,
"logicalOperatorNewline": "before",
"aliasAs": "before",
"commaPosition": "after",
"linesAroundComments": 0,
"tabulateAlias": false,
"newlineBeforeSemicolon": false
} }
}, },
"sqltools.formatOnSave": true, "sqltools.formatOnSave": true,

View File

@@ -1,69 +1,296 @@
# 3.11 Document Numbering Management (การจัดการเลขที่เอกสาร) # 3.11 Document Numbering Management (การจัดการเลขที่เอกสาร)
--- ---
title: 'Functional Requirements: Document Numbering Management' title: 'Functional Requirements: Document Numbering Management'
version: 1.5.0 version: 1.5.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2025-12-02
related: related:
- specs/01-requirements/01-objectives.md - specs/01-requirements/01-objectives.md
- specs/01-requirements/02-architecture.md - specs/01-requirements/02-architecture.md
- specs/01-requirements/03-functional-requirements.md - specs/01-requirements/03-functional-requirements.md
- specs/04-data-dictionary/4_Data_Dictionary_V1_4_4.md
--- ---
## 3.11.1. วัตถุประสงค์ ## 3.11.1. วัตถุประสงค์
- ระบบต้องสามารถสร้างเลขที่เอกสาร (Running Number) ได้โดยอัตโนมัติและยืดหยุ่นสูง - ระบบต้องสามารถสร้างเลขที่เอกสาร (Running Number) ได้โดยอัตโนมัติและยืดหยุ่นสูง
- ระบต้องสามารถกำหนด รูปแบบ(template) เลขที่เอกสารได้ สำหรับแต่ละโครงการ, ชนิดเอกสาร, ประเภทเอกสาร - ระบต้องสามารถกำหนดรูปแบบ (template) เลขที่เอกสารได้ สำหรับแต่ละโครงการ, ชนิดเอกสาร, ประเภทเอกสาร
- ระบบต้องรับประกัน Uniqueness ของเลขที่เอกสารในทุกสถานการณ์
- ระบบต้องรองรับการทำงานแบบ concurrent ได้อย่างปลอดภัย
## 3.11.2. Logic การนับเลข (Counter Logic) ## 3.11.2. Logic การนับเลข (Counter Logic)
- การนับเลขจะต้องรองรับการแยกตาม Key ที่ซับซ้อนขึ้น ตามแต่ละ รูปแบบ(template) ได้ การนับเลขจะแยกตาม **Counter Key** ที่ประกอบด้วย:
## 3.11.3. Format Template - `project_id` - รหัสโครงการ
- `doc_type_id` - ชนิดเอกสาร (Correspondence, RFA, Transmittal, Drawing)
- `sub_type_id` - ประเภทย่อยของเอกสาร (nullable)
- `discipline_id` - สาขาวิชา/งาน (nullable)
- `year` - ปี พ.ศ. หรือ ค.ศ. ตามที่กำหนดใน template
- รองรับการกำหนดรูปแบบด้วย Token Replacement ### ตัวอย่าง Counter Key
- transmittal to owner:
- {ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR B.D.} -> คคง.-สคฉ.3-03-21-0117-2568
- other transmittal:
- {ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR B.D.} -> ผรม.2-คคง.-0117-2568
- RFA:
- {PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV} -> LCBP3-C2-RFI-ROW-0029-A
- Correspondence type LETTER:
- {ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR B.D.} -> คคง.-สคฉ.3-0985-2568
- Correspondence รองรับ Token: {ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR B.D.} -> คคง.-สคฉ.3-STR-0001-2568
- RFA รองรับ Token: {PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV} -> TEAM-RFA-STR-0001-A
- Transmittal รองรับ Token: {PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV} -> TEAM-TR-STR-0001-A
## 3.11.4. Transmittal Logic ```text
Correspondence: project_id + doc_type_id + sub_type_id + year
RFA: project_id + doc_type_id + discipline_id + year
Transmittal: project_id + doc_type_id + recipient_type + year
Drawing: project_id + doc_type_id + discipline_id + year
```
- รองรับเงื่อนไขพิเศษสำหรับ Transmittal ที่เลขอาจเปลี่ยนตามผู้รับ (To Owner vs To Contractor) ### Fallback สำหรับค่า NULL
## 3.11.5. กลไกความปลอดภัย - กรณีที่ `discipline_id` หรือ `sub_type_id` เป็น NULL ให้ใช้ค่า Default `0` ในการจัดกลุ่ม Counter
- ป้องกัน Error และรับประกันความถูกต้องของ Running Number (Uniqueness Guarantee)
- ยังคงใช้ Redis Distributed Lock และ Optimistic Locking เพื่อป้องกันเลขซ้ำหรือข้าม ## 3.11.3. Format Templates by Document Type
## 3.11.6. ต้องมี retry mechanism และ fallback strategy เมื่อการ generate เลขที่เอกสารล้มเหลว ระบบรองรับการกำหนดรูปแบบด้วย **Token Replacement**
## 3.11.7. Fallback Logic (เพิ่ม) ### 3.11.3.1. Correspondence (หนังสือราชการ)
- กรณีที่เอกสารประเภทนั้นไม่มี discipline_id หรือ sub_type_id (เป็นค่า NULL หรือไม่ระบุ) ให้ระบบใช้ค่า Default (เช่น 0) ในการจัดกลุ่ม Counter เพื่อป้องกัน Error และรับประกันความถูกต้องของ Running Number (Uniqueness Guarantee) #### Letter Type (TYPE = 03)
- Scenario 1: Redis Unavailable
- Fallback เป็น database-only locking (pessimistic lock) - **Template**: `{ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR:B.E.}`
- Log warning และแจ้ง ops team - **Example**: `คคง.-สคฉ.3-0985-2568`
- **Counter Key**: `project_id + doc_type_id + sub_type_id + year`
#### Other Correspondence Types
- **Template**: `{ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR:B.E.}`
- **Example**: `คคง.-สคฉ.3-STR-0001-2568`
- **Counter Key**: `project_id + doc_type_id + sub_type_id + year`
### 3.11.3.2. Transmittal
#### Transmittal to Owner
- **Template**: `{ORG}-{ORG}-{TYPE}-{SUB_TYPE}-{SEQ:4}-{YEAR:B.E.}`
- **Example**: `คคง.-สคฉ.3-03-21-0117-2568`
- **Counter Key**: `project_id + doc_type_id + recipient_type + year`
- **Note**: `recipient_type = 'OWNER'`
#### Transmittal to Contractor/Others
- **Template**: `{ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR:B.E.}`
- **Example**: `ผรม.2-คคง.-0117-2568`
- **Counter Key**: `project_id + doc_type_id + recipient_type + year`
- **Note**: `recipient_type = 'CONTRACTOR' | 'CONSULTANT' | 'OTHER'`
#### Alternative Project-based Format
- **Template**: `{PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV}`
- **Example**: `LCBP3-TR-STR-0001-A`
- **Counter Key**: `project_id + doc_type_id + discipline_id + year`
### 3.11.3.3. RFA (Request for Approval)
- **Template**: `{PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV}`
- **Example**: `LCBP3-C2-RFI-ROW-0029-A`
- **Counter Key**: `project_id + doc_type_id + discipline_id + year`
- **Note**: `{REV}` คือ revision code (A, B, C, ..., AA, AB, ...)
### 3.11.3.4. Drawing
- **Template**: `{PROJECT}-{DISCIPLINE}-{CATEGORY}-{SEQ:4}-{REV}`
- **Example**: `LCBP3-STR-DRW-0001-A`
- **Counter Key**: `project_id + doc_type_id + discipline_id + category + year`
## 3.11.4. Supported Token Types
| Token | Description | Example |
|-------|-------------|---------|
| `{PROJECT}` | รหัสโครงการ | `LCBP3` |
| `{ORG}` | รหัสหน่วยงาน | `คคง.`, `สคฉ.3` |
| `{TYPE}` | รหัสชนิดเอกสาร | `RFI`, `03` |
| `{SUB_TYPE}` | รหัสประเภทย่อย | `21` |
| `{DISCIPLINE}` | รหัสสาขาวิชา | `STR`, `ROW` |
| `{CATEGORY}` | หมวดหมู่ | `DRW` |
| `{SEQ:n}` | Running number (n = จำนวนหลัก) | `0001`, `0029` |
| `{YEAR:B.E.}` | ปี พ.ศ. | `2568` |
| `{YEAR:A.D.}` | ปี ค.ศ. | `2025` |
| `{REV}` | Revision Code | `A`, `B`, `AA` |
## 3.11.5. Transmittal Special Logic
- Transmittal มีเงื่อนไขพิเศษที่เลขอาจเปลี่ยนตามผู้รับ:
- **To Owner**: ใช้ format พิเศษที่มี sub_type รหัสโครงการ
- **To Contractor/Others**: ใช้ format ทั่วไป
- Counter Key จะแยกตาม `recipient_type` เพื่อให้แต่ละประเภทมี running number อิสระ
## 3.11.6. กลไกความปลอดภัย (Concurrency Control)
### 3.11.6.1. Redis Distributed Lock
- ใช้ Redis Distributed Lock เพื่อป้องกัน race condition
- Lock key format: `lock:docnum:{project_id}:{doc_type_id}:{...counter_key_parts}`
- Lock TTL: 5 วินาที (auto-release เมื่อ timeout)
- Lock acquisition timeout: 10 วินาที
### 3.11.6.2. Optimistic Locking
- ใช้ `version` column ในตาราง `document_number_configs`
- ตรวจสอบ version ก่อน update counter
- หาก version conflict เกิดขึ้น → retry transaction
### 3.11.6.3. Database Constraints
- Unique constraint บน `document_number` column
- Foreign key constraints เพื่อความสัมพันธ์ข้อมูล
- Check constraints สำหรับ business rules
## 3.11.7. Retry Mechanism & Error Handling
### 3.11.7.1. Scenario 1: Redis Unavailable
- **Fallback**: ใช้ database-only locking (pessimistic lock)
- **Action**:
- ใช้ `SELECT ... FOR UPDATE` แทน Redis lock
- Log warning พร้อม alert ops team
- ระบบยังใช้งานได้แต่ performance ลดลง - ระบบยังใช้งานได้แต่ performance ลดลง
- Scenario 2: Lock Acquisition Timeout
- Retry 5 ครั้งด้วย exponential backoff (1s, 2s, 4s, 8s, 16s) ### 3.11.7.2. Scenario 2: Lock Acquisition Timeout
- หลัง 5 ครั้ง: Return error 503 "Service Temporarily Unavailable"
- Frontend แสดง user-friendly message: "ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง" - **Retry**: 5 ครั้งด้วย exponential backoff
- Scenario 3: Version Conflict After Lock - Attempt 1: wait 1s
- Retry transaction อีก 2 ครั้ง - Attempt 2: wait 2s
- หากยังล้มเหลว: Log error พร้อม context และ return 409 Conflict - Attempt 3: wait 4s
- Frontend แสดง user-friendly message: "เลขที่เอกสารถูกเปลี่ยน กรุณาลองใหม่" - Attempt 4: wait 8s
- Monitoring: - Attempt 5: wait 16s (รวม ~31 วินาที)
- Alert ถ้า lock acquisition failures > 5% ใน 5 นาที - **Failure**: Return HTTP 503 "Service Temporarily Unavailable"
- Dashboard แสดง lock wait time percentiles - **Frontend**: แสดงข้อความ "ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง"
### 3.11.7.3. Scenario 3: Version Conflict After Lock
- **Retry**: 2 ครั้ง (reload counter + retry transaction)
- **Failure**: Log error พร้อม context และ return HTTP 409 Conflict
- **Frontend**: แสดงข้อความ "เลขที่เอกสารถูกเปลี่ยน กรุณาลองใหม่"
### 3.11.7.4. Scenario 4: Database Connection Error
- **Retry**: 3 ครั้งด้วย exponential backoff (1s, 2s, 4s)
- **Failure**: Return HTTP 500 "Internal Server Error"
- **Frontend**: แสดงข้อความ "เกิดข้อผิดพลาดในระบบ กรุณาติดต่อผู้ดูแลระบบ"
## 3.11.8. Configuration Management
### 3.11.8.1. Admin Panel Configuration
- Project Admin สามารถกำหนด/แก้ไข template ผ่าน Admin Panel
- การเปลี่ยนแปลง template จะไม่ส่งผลต่อเอกสารที่สร้างไว้แล้ว
- ต้องมีการ validate template ก่อนบันทึก (ตรวจสอบ token ที่ใช้ถูกต้อง)
### 3.11.8.2. Template Versioning
- เก็บ history ของ template changes
- บันทึก user, timestamp, และเหตุผลในการเปลี่ยนแปลง
- สามารถ rollback ไปเวอร์ชันก่อนหน้าได้
### 3.11.8.3. Counter Reset Policy
- Counter reset ตามปี (yearly reset)
- Counter reset ตาม project phase (optional)
- Admin สามารถ manual reset counter ได้ (require approval + audit log)
## 3.11.9. Audit Trail
### 3.11.9.1. การบันทึก Audit Log
บันทึกทุกการ generate เลขที่เอกสารใน `document_number_audit` table:
- `document_id` - เอกสารที่ถูกสร้าง
- `generated_number` - เลขที่ถูกสร้าง
- `counter_key` - key ที่ใช้ในการนับ
- `template_used` - template ที่ใช้
- `user_id` - ผู้ที่ request
- `ip_address` - IP address ของผู้ request
- `timestamp` - เวลาที่สร้าง
- `retry_count` - จำนวนครั้งที่ retry (ถ้ามี)
### 3.11.9.2. Conflict & Error Logging
- บันทึก version conflicts และ กลไก retry ที่ใช้
- บันทึก lock timeouts และ failure reasons
- บันทึก fallback scenarios (เช่น Redis unavailable)
## 3.11.10. Performance Requirements
### 3.11.10.1. Response Time
- Document number generation ต้องเสร็จภายใน **2 วินาที** (95th percentile)
- Document number generation ต้องเสร็จภายใน **5 วินาที** (99th percentile)
- ในกรณี normal operation (ไม่มี retry) ควรเสร็จภายใน **500ms**
### 3.11.10.2. Throughput
- ระบบรองรับ concurrent requests อย่างน้อย **50 requests/second**
- Peak load รองรับได้ถึง **100 requests/second** (ช่วงเวลาเร่งงาน)
### 3.11.10.3. Availability
- Uptime ≥ 99.5% (exclude planned maintenance)
- Maximum downtime ต่อเดือน ≤ 3.6 ชั่วโมง
## 3.11.11. Monitoring & Alerting
### 3.11.11.1. Metrics to Monitor
- Lock acquisition time (p50, p95, p99)
- Lock acquisition failure rate
- Counter generation latency
- Retry count distribution
- Redis connection status
- Database connection pool usage
### 3.11.11.2. Alert Conditions
- 🔴 **Critical**: Redis unavailable > 1 minute
- 🔴 **Critical**: Lock acquisition failures > 10% in 5 minutes
- 🟡 **Warning**: Lock acquisition failures > 5% in 5 minutes
- 🟡 **Warning**: Average lock wait time > 1 second
- 🟡 **Warning**: Retry count > 100 per hour
### 3.11.11.3. Dashboard
- Real-time lock acquisition success rate
- Lock wait time percentiles (p50, p95, p99)
- Counter generation rate (per minute)
- Error rate breakdown (by error type)
- Redis/Database health status
## 3.11.12. API Reference
เอกสารนี้อ้างอิงถึง API endpoints ต่อไปนี้ (รายละเอียดใน `specs/02-architecture/api-design.md`):
- `POST /api/v1/documents/{documentId}/generate-number` - สร้างเลขที่เอกสาร
- `GET /api/v1/document-numbering/configs` - ดูการตั้งค่า template
- `PUT /api/v1/document-numbering/configs/{configId}` - แก้ไข template (Admin only)
- `POST /api/v1/document-numbering/configs/{configId}/reset-counter` - Reset counter (Admin only)
## 3.11.13. Database Schema Reference
เอกสารนี้อ้างอิงถึง tables ต่อไปนี้ (รายละเอียดใน `specs/04-data-dictionary/4_Data_Dictionary_V1_4_4.md`):
- `document_number_configs` - เก็บ template และ counter configuration
- `document_number_counters` - เก็บ current counter value
- `document_number_audit` - เก็บ audit trail
- `documents` - เก็บ document number ที่ถูกสร้าง
## 3.11.14. Security Considerations
### 3.11.14.1. Authorization
- เฉพาะ authenticated users เท่านั้นที่สามารถ request document number
- เฉพาะ Project Admin เท่านั้นที่แก้ไข template ได้
- เฉพาะ Super Admin เท่านั้นที่ reset counter ได้
### 3.11.14.2. Rate Limiting
- Limit ต่อ user: **10 requests/minute** (prevent abuse)
- Limit ต่อ IP: **50 requests/minute**
### 3.11.14.3. Audit & Compliance
- บันทึกทุก API call ที่เกี่ยวข้องกับ document numbering
- เก็บ audit log อย่างน้อย **7 ปี** (ตาม พ.ร.บ. ข้อมูลอิเล็กทรอนิกส์)

View File

@@ -1,7 +1,7 @@
# ADR-002: Document Numbering Strategy # ADR-002: Document Numbering Strategy
**Status:** Accepted **Status:** Accepted
**Date:** 2025-11-30 **Date:** 2025-12-02
**Decision Makers:** Development Team, System Architect **Decision Makers:** Development Team, System Architect
**Related Documents:** **Related Documents:**
@@ -12,32 +12,34 @@
## Context and Problem Statement ## Context and Problem Statement
LCBP3-DMS ต้องสร้างเลขที่เอกสารอัตโนมัติสำหรับ Correspondences และ RF LCBP3-DMS ต้องสร้างเลขที่เอกสารอัตโนมัติสำหรับ Correspondence, RFA, Transmittal และ Drawing โดยเลขที่เอกสารต้อง:
As โดยเลขที่เอกสารต้อง:
1. **Unique:** ไม่ซ้ำกันในระบบ 1. **Unique:** ไม่ซ้ำกันในระบบ
2. **Sequential:** เรียงตามลำดับเวลา 2. **Sequential:** เรียงตามลำดับเวลา
3. **Meaningful:** มีโครงสร้างที่อ่านเข้าใจได้ (เช่น `TEAM-RFA-STR-2025-0001`) 3. **Meaningful:** มีโครงสร้างที่อ่านเข้าใจได้ (เช่น `LCBP3-C2-RFI-ROW-0029-A`)
4. **Configurable:** สามารถปรับรูปแบบได้ตาม Project/Organization 4. **Configurable:** สามารถปรับรูปแบบได้ตาม Project/Organization/Document Type
5. **Concurrent-safe:** ป้องกัน Race Condition เมื่อมีหลาย Request พร้อมกัน 5. **Concurrent-safe:** ป้องกัน Race Condition เมื่อมีหลาย Request พร้อมกัน
### Key Challenges ### Key Challenges
1. **Race Condition:** เมื่อมี 2+ requests พร้อมกัน อาจได้เลขเดียวกัน 1. **Race Condition:** เมื่อมี 2+ requests พร้อมกัน อาจได้เลขเดียวกัน
2. **Performance:** ต้องรวดเร็วแม้มี concurrent requests 2. **Performance:** ต้องรวดเร็วแม้มี concurrent requests (50-100 req/sec)
3. **Flexibility:** รองรับรูปแบบเลขที่หลากหลาย 3. **Flexibility:** รองรับรูปแบบเลขที่หลากหลายตามชนิดเอกสาร
4. **Discipline Support:** เลขที่ต้องรวม Discipline Code (GEN, STR, ARC) 4. **Discipline Support:** เลขที่ต้องรวม Discipline Code (GEN, STR, ARC, etc.)
5. **Transmittal Logic:** เลขที่ Transmittal เปลี่ยนตามผู้รับ (To Owner vs To Contractor)
6. **Year Reset:** Counter ต้อง reset ตาม ปี พ.ศ. หรือ ค.ศ.
--- ---
## Decision Drivers ## Decision Drivers
- **Data Integrity:** เลขที่ต้องไม่ซ้ำกันเด็ดขาด - **Data Integrity:** เลขที่ต้องไม่ซ้ำกันเด็ดขาด (Mission-Critical)
- **Performance:** Generate เลขที่ได้เร็ว (< 100ms) - **Performance:** Generate เลขที่ได้เร็ว (<500ms normal, <2s p95, <5s p99)
- **Scalability:** รองรับ concurrent requests สูง - **Scalability:** รองรับ 50-100 concurrent requests/second
- **Maintainability:** ง่ายต่อการ Config และ Debug - **Maintainability:** ง่ายต่อการ Config และ Debug
- **Flexibility:** รองรับรูปแบบที่หลากหลาย - **Flexibility:** รองรับ Template-based format สำหรับแต่ละ document type
- **Auditability:** บันทึก history ของทุก generated number
- **Security:** ป้องกัน abuse ด้วย rate limiting
--- ---
@@ -56,8 +58,8 @@ As โดยเลขที่เอกสารต้อง:
**Cons:** **Cons:**
- ❌ ไม่ Configurable (รูปแบบเลขที่ fixed) - ❌ ไม่ Configurable (รูปแบบเลขที่ fixed)
- ❌ ยากต่อการ Partition by Project/Type/Year - ❌ ยากต่อการ Partition by Project/Type/Discipline/Year
- ❌ ไม่รองรับ Custom format (เช่น `TEAM-RFA-2025-0001`) - ❌ ไม่รองรับ Custom format (เช่น `LCBP3-RFA-2025-0001`)
- ❌ Reset ตาม Year ทำได้ยาก - ❌ Reset ตาม Year ทำได้ยาก
### Option 2: Application-Level Counter (Single Lock) ### Option 2: Application-Level Counter (Single Lock)
@@ -83,11 +85,12 @@ As โดยเลขที่เอกสารต้อง:
**Pros:** **Pros:**
-**Guaranteed Uniqueness:** Double-layer protection -**Guaranteed Uniqueness:** Double-layer protection
-**Fast Performance:** Redis lock prevents most conflicts -**Fast Performance:** Redis lock prevents most conflicts (<500ms)
-**Audit Trail:** Counter history in database -**Audit Trail:** Counter history + audit log in database
-**Configurable Format:** Template-based generation -**Configurable Format:** Template-based generation
-**Resilient:** Fallback to DB if Redis issues -**Resilient:** Fallback to DB pessimistic lock if Redis unavailable
-**Partition Support:** Different counters per Project/Type/Discipline/Year -**Partition Support:** Different counters per Project/Type/SubType/Discipline/Year
-**Transmittal Logic:** Support recipient-based counting
**Cons:** **Cons:**
@@ -107,8 +110,8 @@ As โดยเลขที่เอกสารต้อง:
1. **Mission-Critical:** เลขที่เอกสารต้องถูกต้อง 100% (ไม่ยอมรับการซ้ำ) 1. **Mission-Critical:** เลขที่เอกสารต้องถูกต้อง 100% (ไม่ยอมรับการซ้ำ)
2. **Performance + Safety:** Balance ระหว่างความเร็วและความปลอดภัย 2. **Performance + Safety:** Balance ระหว่างความเร็วและความปลอดภัย
3. **Auditability:** มี Counter history ใน Database 3. **Auditability:** มี Counter history + Audit log ใน Database
4. **Flexibility:** รองรับ Template-based format 4. **Flexibility:** รองรับ Template-based format สำหรับทุก document type
5. **Resilience:** ถ้า Redis มีปัญหา ยัง Fallback ไปใช้ DB Lock ได้ 5. **Resilience:** ถ้า Redis มีปัญหา ยัง Fallback ไปใช้ DB Lock ได้
--- ---
@@ -119,42 +122,139 @@ As โดยเลขที่เอกสารต้อง:
```sql ```sql
-- Format Templates -- Format Templates
CREATE TABLE document_number_formats ( CREATE TABLE document_number_configs (
id INT PRIMARY KEY AUTO_INCREMENT, id INT PRIMARY KEY AUTO_INCREMENT,
project_id INT NOT NULL, project_id INT NOT NULL,
correspondence_type_id INT NOT NULL, doc_type_id INT NOT NULL COMMENT 'Correspondence, RFA, Transmittal, Drawing',
format_template VARCHAR(255) NOT NULL, sub_type_id INT DEFAULT 0 COMMENT 'ประเภทย่อย (nullable, use 0 for fallback)',
-- Example: '{ORG_CODE}-{TYPE_CODE}-{DISCIPLINE_CODE}-{YEAR}-{SEQ:4}' discipline_id INT DEFAULT 0 COMMENT 'สาขาวิชา (nullable, use 0 for fallback)',
template VARCHAR(255) NOT NULL COMMENT 'e.g. {PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV}',
description TEXT, description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
version INT DEFAULT 0 NOT NULL COMMENT 'For template versioning',
FOREIGN KEY (project_id) REFERENCES projects(id), FOREIGN KEY (project_id) REFERENCES projects(id),
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id), FOREIGN KEY (doc_type_id) REFERENCES document_types(id),
UNIQUE KEY (project_id, correspondence_type_id) UNIQUE KEY unique_config (project_id, doc_type_id, sub_type_id, discipline_id)
); ) ENGINE=InnoDB COMMENT='Template configurations for document numbering';
-- Counter Table with Optimistic Locking -- Counter Table with Optimistic Locking
CREATE TABLE document_number_counters ( CREATE TABLE document_number_counters (
project_id INT NOT NULL, project_id INT NOT NULL,
originator_organization_id INT NOT NULL, doc_type_id INT NOT NULL,
correspondence_type_id INT NOT NULL, sub_type_id INT DEFAULT 0 COMMENT 'For Correspondence types, 0 = fallback',
discipline_id INT DEFAULT 0, -- 0 = no discipline discipline_id INT DEFAULT 0 COMMENT 'For RFA/Drawing, 0 = fallback',
current_year INT NOT NULL, recipient_type VARCHAR(20) DEFAULT NULL COMMENT 'For Transmittal: OWNER, CONTRACTOR, CONSULTANT, OTHER',
year INT NOT NULL COMMENT 'ปี พ.ศ. หรือ ค.ศ. ตาม template',
last_number INT DEFAULT 0, last_number INT DEFAULT 0,
version INT DEFAULT 0 NOT NULL, -- Version for Optimistic Lock version INT DEFAULT 0 NOT NULL COMMENT 'Version for Optimistic Lock',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (project_id, originator_organization_id, correspondence_type_id, discipline_id, current_year), PRIMARY KEY (project_id, doc_type_id, sub_type_id, discipline_id, COALESCE(recipient_type, ''), year),
FOREIGN KEY (project_id) REFERENCES projects(id), FOREIGN KEY (project_id) REFERENCES projects(id),
FOREIGN KEY (originator_organization_id) REFERENCES organizations(id), FOREIGN KEY (doc_type_id) REFERENCES document_types(id),
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id), INDEX idx_counter_lookup (project_id, doc_type_id, year)
FOREIGN KEY (discipline_id) REFERENCES disciplines(id) ) ENGINE=InnoDB COMMENT='Running number counters with optimistic locking';
);
-- Audit Trail
CREATE TABLE document_number_audit (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
document_id INT DEFAULT NULL COMMENT 'FK to documents (set after doc creation)',
generated_number VARCHAR(255) NOT NULL,
counter_key VARCHAR(500) NOT NULL COMMENT 'Redis lock key used',
template_used VARCHAR(255) NOT NULL,
sequence_number INT NOT NULL,
user_id INT NOT NULL,
ip_address VARCHAR(45),
retry_count INT DEFAULT 0,
lock_wait_ms INT DEFAULT 0 COMMENT 'Time spent waiting for lock',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_audit_number (generated_number),
INDEX idx_audit_user (user_id, created_at),
INDEX idx_audit_created (created_at)
) ENGINE=InnoDB COMMENT='Audit trail for all generated document numbers';
``` ```
### NestJS Service Implementation ### Token Types Reference
รองรับ Token ทั้งหมด 9 ประเภท:
| Token | Description | Example Value |
|-------|-------------|---------------|
| `{PROJECT}` | รหัสโครงการ | `LCBP3` |
| `{ORG}` | รหัสหน่วยงาน | `คคง.`, `C2` |
| `{TYPE}` | รหัสชนิดเอกสาร | `RFI`, `03` |
| `{SUB_TYPE}` | รหัสประเภทย่อย | `21` |
| `{DISCIPLINE}` | รหัสสาขาวิชา | `STR`, `ROW` |
| `{CATEGORY}` | หมวดหมู่ | `DRW` |
| `{SEQ:n}` | Running number (n digits) | `0001`, `00029` |
| `{YEAR:B.E.}` | ปี พ.ศ. | `2568` |
| `{YEAR:A.D.}` | ปี ค.ศ. | `2025` |
| `{REV}` | Revision Code | `A`, `B`, `AA` |
### Format Examples by Document Type
#### 1. Correspondence (หนังสือราชการ)
**Letter Type (TYPE = 03):**
```
Template: {ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR:B.E.}
Example: คคง.-สคฉ.3-0985-2568
Counter Key: project_id + doc_type_id + sub_type_id + year
```
**Other Correspondence:**
```
Template: {ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR:B.E.}
Example: คคง.-สคฉ.3-STR-0001-2568
Counter Key: project_id + doc_type_id + sub_type_id + year
```
#### 2. Transmittal
**To Owner (Special Format):**
```
Template: {ORG}-{ORG}-{TYPE}-{SUB_TYPE}-{SEQ:4}-{YEAR:B.E.}
Example: คคง.-สคฉ.3-03-21-0117-2568
Counter Key: project_id + doc_type_id + recipient_type('OWNER') + year
Note: recipient_type แยก counter จาก To Contractor
```
**To Contractor/Others:**
```
Template: {ORG}-{ORG}-{TYPE}-{SEQ:4}-{YEAR:B.E.}
Example: ผรม.2-คคง.-0117-2568
Counter Key: project_id + doc_type_id + recipient_type('CONTRACTOR') + year
```
**Alternative Project-based:**
```
Template: {PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV}
Example: LCBP3-TR-STR-0001-A
Counter Key: project_id + doc_type_id + discipline_id + year
```
#### 3. RFA (Request for Approval)
```
Template: {PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV}
Example: LCBP3-C2-RFI-ROW-0029-A
Counter Key: project_id + doc_type_id + discipline_id + year
```
#### 4. Drawing
```
Template: {PROJECT}-{DISCIPLINE}-{CATEGORY}-{SEQ:4}-{REV}
Example: LCBP3-STR-DRW-0001-A
Counter Key: project_id + doc_type_id + discipline_id + category + year
```
### NestJS Service Implementation (Simplified)
```typescript ```typescript
// document-numbering.service.ts // File: backend/src/modules/document-numbering/document-numbering.service.ts
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import Redlock from 'redlock'; import Redlock from 'redlock';
@@ -162,40 +262,91 @@ import Redis from 'ioredis';
interface NumberingContext { interface NumberingContext {
projectId: number; projectId: number;
organizationId: number; docTypeId: number;
typeId: number; subTypeId?: number;
disciplineId?: number; disciplineId?: number;
recipientType?: 'OWNER' | 'CONTRACTOR' | 'CONSULTANT' | 'OTHER';
year?: number; year?: number;
userId: number;
ipAddress: string;
} }
@Injectable() @Injectable()
export class DocumentNumberingService { export class DocumentNumberingService {
private readonly logger = new Logger(DocumentNumberingService.name);
constructor( constructor(
@InjectRepository(DocumentNumberCounter) @InjectRepository(DocumentNumberCounter)
private counterRepo: Repository<DocumentNumberCounter>, private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(DocumentNumberFormat) @InjectRepository(DocumentNumberConfig)
private formatRepo: Repository<DocumentNumberFormat>, private configRepo: Repository<DocumentNumberConfig>,
@InjectRepository(DocumentNumberAudit)
private auditRepo: Repository<DocumentNumberAudit>,
private redis: Redis, private redis: Redis,
private redlock: Redlock private redlock: Redlock
) {} ) {}
async generateNextNumber(context: NumberingContext): Promise<string> { async generateNextNumber(context: NumberingContext): Promise<string> {
const year = context.year || new Date().getFullYear(); const year = context.year || new Date().getFullYear() + 543; // พ.ศ.
const disciplineId = context.disciplineId || 0; const subTypeId = context.subTypeId || 0; // Fallback for NULL
const disciplineId = context.disciplineId || 0; // Fallback for NULL
// Step 1: Acquire Redis Distributed Lock // Build Redis lock key
const lockKey = `doc_num:${context.projectId}:${context.organizationId}:${context.typeId}:${disciplineId}:${year}`; const lockKey = this.buildLockKey(
const lock = await this.redlock.acquire([lockKey], 3000); // 3 second TTL context.projectId,
context.docTypeId,
subTypeId,
disciplineId,
context.recipientType,
year
);
// Retry with exponential backoff (Scenario 2 & 3)
return this.retryWithBackoff(
async () => await this.generateNumberWithLock(
lockKey,
context,
year,
subTypeId,
disciplineId
),
5, // Max 5 retries
1000 // Initial delay 1s
);
}
private async generateNumberWithLock(
lockKey: string,
context: NumberingContext,
year: number,
subTypeId: number,
disciplineId: number
): Promise<string> {
let lock: any;
const lockStartTime = Date.now();
try { try {
// Scenario 1: Redis Unavailable - Fallback to DB lock
try {
// Step 1: Acquire Redis Distributed Lock (TTL: 5 seconds)
lock = await this.redlock.acquire([lockKey], 5000);
} catch (redisError) {
this.logger.warn(`Redis lock failed, falling back to DB lock: ${redisError.message}`);
// Fallback: Use SELECT ... FOR UPDATE (Pessimistic Lock)
return await this.generateWithDatabaseLock(context, year, subTypeId, disciplineId);
}
const lockWaitMs = Date.now() - lockStartTime;
// Step 2: Query current counter with version // Step 2: Query current counter with version
let counter = await this.counterRepo.findOne({ let counter = await this.counterRepo.findOne({
where: { where: {
project_id: context.projectId, project_id: context.projectId,
originator_organization_id: context.organizationId, doc_type_id: context.docTypeId,
correspondence_type_id: context.typeId, sub_type_id: subTypeId,
discipline_id: disciplineId, discipline_id: disciplineId,
current_year: year, recipient_type: context.recipientType || null,
year: year,
}, },
}); });
@@ -203,19 +354,21 @@ export class DocumentNumberingService {
if (!counter) { if (!counter) {
counter = this.counterRepo.create({ counter = this.counterRepo.create({
project_id: context.projectId, project_id: context.projectId,
originator_organization_id: context.organizationId, doc_type_id: context.docTypeId,
correspondence_type_id: context.typeId, sub_type_id: subTypeId,
discipline_id: disciplineId, discipline_id: disciplineId,
current_year: year, recipient_type: context.recipientType || null,
year: year,
last_number: 0, last_number: 0,
version: 0, version: 0,
}); });
await this.counterRepo.save(counter);
} }
const currentVersion = counter.version; const currentVersion = counter.version;
const nextNumber = counter.last_number + 1; const nextNumber = counter.last_number + 1;
// Step 3: Update counter with Optimistic Lock check // Step 3: Update counter with Optimistic Lock check (Scenario 3)
const result = await this.counterRepo const result = await this.counterRepo
.createQueryBuilder() .createQueryBuilder()
.update(DocumentNumberCounter) .update(DocumentNumberCounter)
@@ -225,54 +378,154 @@ export class DocumentNumberingService {
}) })
.where({ .where({
project_id: context.projectId, project_id: context.projectId,
originator_organization_id: context.organizationId, doc_type_id: context.docTypeId,
correspondence_type_id: context.typeId, sub_type_id: subTypeId,
discipline_id: disciplineId, discipline_id: disciplineId,
current_year: year, recipient_type: context.recipientType || null,
year: year,
version: currentVersion, // Optimistic lock check version: currentVersion, // Optimistic lock check
}) })
.execute(); .execute();
if (result.affected === 0) { if (result.affected === 0) {
throw new Error('Optimistic lock conflict - counter version changed'); throw new ConflictException('Counter version conflict - retrying...');
} }
// Step 4: Generate formatted number // Step 4: Generate formatted number
const format = await this.getFormat(context.projectId, context.typeId); const config = await this.getConfig(
const formattedNumber = await this.formatNumber(format, { context.projectId,
context.docTypeId,
subTypeId,
disciplineId
);
const formattedNumber = await this.formatNumber(config.template, {
...context, ...context,
year, year,
sequenceNumber: nextNumber, sequenceNumber: nextNumber,
}); });
// Step 5: Audit logging
await this.auditRepo.save({
generated_number: formattedNumber,
counter_key: lockKey,
template_used: config.template,
sequence_number: nextNumber,
user_id: context.userId,
ip_address: context.ipAddress,
retry_count: 0,
lock_wait_ms: lockWaitMs,
});
this.logger.log(`Generated: ${formattedNumber} (wait: ${lockWaitMs}ms)`);
return formattedNumber; return formattedNumber;
} finally { } finally {
// Step 5: Release Redis lock // Step 6: Release Redis lock
if (lock) {
await lock.release(); await lock.release();
} }
} }
}
private async formatNumber( private async formatNumber(template: string, data: any): Promise<string> {
format: DocumentNumberFormat, // Token replacement logic
data: any
): Promise<string> {
let result = format.format_template;
// Replace tokens
const tokens = { const tokens = {
'{ORG_CODE}': await this.getOrgCode(data.organizationId), '{PROJECT}': await this.getProjectCode(data.projectId),
'{TYPE_CODE}': await this.getTypeCode(data.typeId), '{ORG}': await this.getOrgCode(data.organizationId),
'{DISCIPLINE_CODE}': await this.getDisciplineCode(data.disciplineId), '{TYPE}': await this.getTypeCode(data.docTypeId),
'{YEAR}': data.year.toString(), '{SUB_TYPE}': await this.getSubTypeCode(data.subTypeId),
'{DISCIPLINE}': await this.getDisciplineCode(data.disciplineId),
'{CATEGORY}': await this.getCategoryCode(data.categoryId),
'{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'), '{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'),
'{SEQ:5}': data.sequenceNumber.toString().padStart(5, '0'),
'{YEAR:B.E.}': data.year.toString(),
'{YEAR:A.D.}': (data.year - 543).toString(),
'{REV}': data.revisionCode || 'A',
}; };
let result = template;
for (const [token, value] of Object.entries(tokens)) { for (const [token, value] of Object.entries(tokens)) {
result = result.replace(token, value); result = result.replace(new RegExp(token, 'g'), value);
} }
return result; return result;
} }
private buildLockKey(...parts: Array<number | string | null | undefined>): string {
return `doc_num:${parts.filter(p => p !== null && p !== undefined).join(':')}`;
}
// Scenario 2: Lock Acquisition Timeout - Exponential Backoff
private async retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number,
initialDelay: number
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryable =
error instanceof ConflictException ||
error.code === 'ECONNREFUSED' || // Scenario 4
error.code === 'ETIMEDOUT'; // Scenario 4
if (!isRetryable || attempt === maxRetries) {
if (attempt === maxRetries) {
throw new ServiceUnavailableException(
'ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง'
);
}
throw error;
}
const delay = initialDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
this.logger.warn(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
}
}
}
// Scenario 1: Fallback to Database Lock
private async generateWithDatabaseLock(
context: NumberingContext,
year: number,
subTypeId: number,
disciplineId: number
): Promise<string> {
return await this.counterRepo.manager.transaction(async (manager) => {
// Pessimistic lock: SELECT ... FOR UPDATE
const counter = await manager
.createQueryBuilder(DocumentNumberCounter, 'counter')
.setLock('pessimistic_write')
.where({
project_id: context.projectId,
doc_type_id: context.docTypeId,
sub_type_id: subTypeId,
discipline_id: disciplineId,
recipient_type: context.recipientType || null,
year: year,
})
.getOne();
const nextNumber = (counter?.last_number || 0) + 1;
// Update counter
await manager.save(DocumentNumberCounter, {
...counter,
last_number: nextNumber,
});
// Format and return
const config = await this.getConfig(context.projectId, context.docTypeId, subTypeId, disciplineId);
return await this.formatNumber(config.template, {
...context,
year,
sequenceNumber: nextNumber,
});
});
}
} }
``` ```
@@ -280,62 +533,209 @@ export class DocumentNumberingService {
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Service as Correspondence Service participant Client
participant Numbering as Numbering Service participant Service as Numbering Service
participant Redis participant Redis
participant DB as MariaDB participant DB as MariaDB
participant Audit
Service->>Numbering: generateNextNumber(context) Client->>Service: generateNextNumber(context)
Numbering->>Redis: ACQUIRE Lock (key) Service->>Redis: ACQUIRE Lock (key, TTL=5s)
alt Lock Acquired alt Redis Available
Redis-->>Numbering: Lock Success Redis-->>Service: Lock Success
Numbering->>DB: SELECT counter (with version) Service->>DB: SELECT counter (with version)
DB-->>Numbering: current_number, version DB-->>Service: current_number, version
Numbering->>DB: UPDATE counter SET last_number = X, version = version + 1<br/>WHERE version = old_version Service->>DB: UPDATE counter SET last_number=X, version=version+1<br/>WHERE version=old_version
alt Update Success alt Update Success (No Conflict)
DB-->>Numbering: Success (1 row affected) DB-->>Service: Success (1 row affected)
Numbering->>Numbering: Format Number Service->>Service: Format Number with Template
Numbering->>Redis: RELEASE Lock Service->>Audit: Log generated number + metadata
Numbering-->>Service: Document Number Service->>Redis: RELEASE Lock
else Version Conflict Service-->>Client: Document Number
DB-->>Numbering: Failed (0 rows affected) else Version Conflict (Scenario 3)
Numbering->>Redis: RELEASE Lock DB-->>Service: Failed (0 rows affected)
Numbering->>Numbering: Retry with Exponential Backoff Service->>Redis: RELEASE Lock
Service->>Service: Retry with Exponential Backoff (2x)
Note over Service: If still fail after 2 retries:<br/>Return 409 Conflict
end end
else Lock Failed else Redis Unavailable (Scenario 1)
Redis-->>Numbering: Lock Timeout Redis-->>Service: Connection Error
Numbering-->>Service: Error: Unable to acquire lock Service->>DB: BEGIN TRANSACTION
Service->>DB: SELECT ... FOR UPDATE (Pessimistic Lock)
DB-->>Service: Counter (locked)
Service->>DB: UPDATE counter
Service->>DB: COMMIT
Service-->>Client: Document Number (slower but works)
end
alt Lock Timeout (Scenario 2)
Redis-->>Service: Lock Acquisition Timeout
Service->>Service: Retry 5 times with backoff<br/>(1s, 2s, 4s, 8s, 16s)
Note over Service: If all retries fail:<br/>Return 503 Service Unavailable
end end
``` ```
--- ---
## Error Handling Scenarios
### Scenario 1: Redis Unavailable
**Trigger:** Redis connection error, Redis down
**Fallback:**
- ใช้ Database-only locking (`SELECT ... FOR UPDATE`)
- Log warning และแจ้ง ops team
- ระบบยังใช้งานได้แต่ performance ลดลง (slower)
### Scenario 2: Lock Acquisition Timeout
**Trigger:** หลาย requests แย่งชิง lock พร้อมกัน
**Retry Logic:**
- Retry 5 ครั้งด้วย exponential backoff: 1s, 2s, 4s, 8s, 16s (รวม ~31 วินาที)
- หลัง 5 ครั้ง: Return HTTP 503 "Service Temporarily Unavailable"
- Frontend: แสดง "ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง"
### Scenario 3: Version Conflict After Lock
**Trigger:** Optimistic lock version mismatch
**Retry Logic:**
- Retry 2 ครั้ง (reload counter + retry transaction)
- หลัง 2 ครั้ง: Return HTTP 409 Conflict
- Frontend: แสดง "เลขที่เอกสารถูกเปลี่ยน กรุณาลองใหม่"
### Scenario 4: Database Connection Error
**Trigger:** Database connection timeout, connection pool exhausted
**Retry Logic:**
- Retry 3 ครั้งด้วย exponential backoff: 1s, 2s, 4s
- หลัง 3 ครั้ง: Return HTTP 500 "Internal Server Error"
- Frontend: แสดง "เกิดข้อผิดพลาดในระบบ กรุณาติดต่อผู้ดูแลระบบ"
---
## Performance Requirements
### Response Time Targets
| Metric | Target | Description |
|--------|--------|-------------|
| Normal Operation | <500ms | Under normal load, no conflicts |
| 95th Percentile | <2 seconds | Including retry scenarios |
| 99th Percentile | <5 seconds | Extreme cases with multiple retries |
### Throughput Targets
| Load Level | Target | Notes |
|------------|--------|-------|
| Normal Load | 50 req/sec | Typical office hours |
| Peak Load | 100 req/sec | Construction deadline periods |
### Availability
- **Uptime:** ≥99.5% (exclude planned maintenance)
- **Maximum Downtime:** ≤3.6 hours/month
---
## Monitoring & Alerting
### Metrics to Track
1. **Lock Acquisition Metrics:**
- Lock wait time (p50, p95, p99)
- Lock acquisition success rate
- Lock timeout count
2. **Counter Generation:**
- Generation latency (p50, p95, p99)
- Generation success rate
- Retry count distribution
3. **System Health:**
- Redis connection status
- Database connection pool usage
- Error rate by scenario (1-4)
### Alert Conditions
| Severity | Condition | Action |
|----------|-----------|--------|
| 🔴 Critical | Redis unavailable >1 minute | Page ops team |
| 🔴 Critical | Lock failures >10% in 5 min | Page ops team |
| 🟡 Warning | Lock failures >5% in 5 min | Alert ops team |
| 🟡 Warning | Avg lock wait time >1 second | Alert ops team |
| 🟡 Warning | Retry count >100/hour | Review system load |
### Dashboard Panels
- Real-time lock acquisition success rate (%)
- Lock wait time percentiles chart
- Counter generation rate (per minute)
- Error rate breakdown by type
- Redis/Database health indicators
---
## Security Considerations
### Authorization
- เฉพาะ **authenticated users** สามารถ request document number
- เฉพาะ **Project Admin** สามารถแก้ไข template
- เฉพาะ **Super Admin** สามารถ reset counter
### Rate Limiting
Prevent abuse และ resource exhaustion:
| Scope | Limit | Window |
|-------|-------|--------|
| Per User | 10 requests | 1 minute |
| Per IP Address | 50 requests | 1 minute |
| Global | 5000 requests | 1 minute |
**Implementation:** ใช้ Redis-based rate limiter middleware
### Audit & Compliance
- บันทึกทุก API call ที่เกี่ยวข้องกับ document numbering
- เก็บ audit log อย่างน้อย **7 ปี** (ตาม พ.ร.บ. ข้อมูลอิเล็กทรอนิกส์)
- บันทึก: user, IP, timestamp, generated number, retry count
---
## Consequences ## Consequences
### Positive ### Positive
1.**Zero Duplicate Risk:** Double-lock guarantees uniqueness 1.**Zero Duplicate Risk:** Double-lock + DB constraint guarantees uniqueness
2.**High Performance:** Redis lock prevents most DB conflicts (< 100ms) 2.**High Performance:** Redis lock + optimistic locking (<500ms normal)
3.**Audit Trail:** All counters stored in database 3.**Complete Audit Trail:** All counters + generated numbers in database
4.**Template-Based:** Easy to configure different formats 4.**Highly Configurable:** Template-based for all document types
5.**Partition Support:** Separate counters per Project/Type/Discipline/Year 5.**Partition Support:** Separate counters per Project/Type/SubType/Discipline/Recipient/Year
6.**Discipline Integration:** รองรับ Discipline Code ตาม Requirement 6B 6.**Resilient:** Multiple fallback strategies for all failure scenarios
7.**Transmittal Logic:** Supports recipient-based numbering
8.**Security:** Rate limiting + authorization + audit logging
### Negative ### Negative
1.**Complexity:** Requires Redis + Database coordination 1.**Complexity:** Requires coordination between Redis and Database
2.**Dependencies:** Requires both Redis and DB healthy 2.**Dependencies:** Requires both Redis and DB healthy for optimal performance
3.**Retry Logic:** May retry on optimistic lock conflicts 3.**Retry Logic:** May retry causing delays under high contention
4.**Monitoring:** Need to monitor lock acquisition times 4.**Monitoring Overhead:** Need comprehensive monitoring for all scenarios
### Mitigation Strategies ### Mitigation Strategies
- **Redis Dependency:** Use Redis Persistence (AOF) และ Replication - **Redis Dependency:** Use Redis Persistence (AOF) + Replication + Fallback to DB
- **Complexity:** Encapsulate logic in `DocumentNumberingService` - **Complexity:** Encapsulate all logic in `DocumentNumberingService`
- **Retry:** Exponential backoff with max 3 retries - **Retry Delays:** Exponential backoff limits max delay time
- **Monitoring:** Track lock wait times และ conflict rates - **Monitoring:** Automated dashboards + alerting for all critical metrics
--- ---
@@ -346,12 +746,13 @@ sequenceDiagram
```typescript ```typescript
describe('DocumentNumberingService - Concurrent Generation', () => { describe('DocumentNumberingService - Concurrent Generation', () => {
it('should generate unique numbers for 100 concurrent requests', async () => { it('should generate unique numbers for 100 concurrent requests', async () => {
const context = { const context: NumberingContext = {
projectId: 1, projectId: 1,
organizationId: 1, docTypeId: 2, // RFA
typeId: 1, disciplineId: 3, // STR
disciplineId: 2, // STR year: 2568,
year: 2025, userId: 1,
ipAddress: '192.168.1.1',
}; };
const promises = Array(100) const promises = Array(100)
@@ -364,22 +765,61 @@ describe('DocumentNumberingService - Concurrent Generation', () => {
const unique = new Set(results); const unique = new Set(results);
expect(unique.size).toBe(100); expect(unique.size).toBe(100);
// Check sequential // Check format
const numbers = results.map((r) => parseInt(r.split('-').pop())); results.forEach(num => {
const sorted = [...numbers].sort((a, b) => a - b); expect(num).toMatch(/^LCBP3-C2-RFI-STR-\d{4}-A$/);
expect(numbers.every((n, i) => sorted.includes(n))).toBe(true); });
}); });
it('should use correct format template', async () => { it('should use correct format for Transmittal To Owner', async () => {
const number = await service.generateNextNumber({ const number = await service.generateNextNumber({
projectId: 1, projectId: 1,
organizationId: 3, // TEAM docTypeId: 3, // Transmittal
typeId: 1, // RFA recipientType: 'OWNER',
disciplineId: 2, // STR year: 2568,
year: 2025, userId: 1,
ipAddress: '192.168.1.1',
}); });
expect(number).toMatch(/^TEAM-RFA-STR-2025-\d{4}$/); expect(number).toMatch(/^คคง\.-สคฉ\.3-03-21-\d{4}-2568$/);
});
it('should fallback to DB lock when Redis unavailable', async () => {
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Redis down'));
const number = await service.generateNextNumber(context);
expect(number).toBeDefined();
expect(loggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('falling back to DB lock'));
});
it('should retry on version conflict and succeed', async () => {
let attempt = 0;
jest.spyOn(counterRepo, 'createQueryBuilder').mockImplementation(() => {
attempt++;
return {
update: () => ({
set: () => ({
where: () => ({
execute: async () => ({
affected: attempt === 1 ? 0 : 1, // Fail first, succeed second
}),
}),
}),
}),
} as any;
});
const result = await service.generateNextNumber(context);
expect(result).toBeDefined();
expect(attempt).toBe(2);
});
it('should throw 503 after max lock acquisition retries', async () => {
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout'));
await expect(service.generateNextNumber(context))
.rejects
.toThrow(ServiceUnavailableException);
}); });
}); });
``` ```
@@ -387,23 +827,59 @@ describe('DocumentNumberingService - Concurrent Generation', () => {
### Load Testing ### Load Testing
```yaml ```yaml
# Artillery configuration # artillery.yml
config: config:
target: 'http://localhost:3000' target: 'http://localhost:3000'
phases: phases:
- duration: 60 - duration: 60
arrivalRate: 50 # 50 requests/second arrivalRate: 50 # 50 requests/second
name: 'Normal Load'
- duration: 30
arrivalRate: 100 # 100 requests/second
name: 'Peak Load'
scenarios: scenarios:
- name: 'Generate Document Numbers' - name: 'Generate Document Numbers - RFA'
weight: 40
flow: flow:
- post: - post:
url: '/correspondences' url: '/api/v1/rfa'
json: json:
title: 'Load Test {{ $randomString() }}' title: 'Load Test {{ $randomString() }}'
project_id: 1 project_id: 1
type_id: 1 doc_type_id: 2
discipline_id: 2 discipline_id: 3
- name: 'Generate Document Numbers - Transmittal'
weight: 30
flow:
- post:
url: '/api/v1/transmittals'
json:
title: 'Load Test {{ $randomString() }}'
project_id: 1
doc_type_id: 3
recipient_type: 'OWNER'
- name: 'Generate Document Numbers - Correspondence'
weight: 30
flow:
- post:
url: '/api/v1/correspondences'
json:
title: 'Load Test {{ $randomString() }}'
project_id: 1
doc_type_id: 1
expect:
- statusCode: 200
- statusCode: 201
- contentType: json
ensure:
p95: 2000 # 95th percentile < 2 seconds
p99: 5000 # 99th percentile < 5 seconds
maxErrorRate: 0.1 # < 0.1% errors
``` ```
--- ---
@@ -412,21 +888,34 @@ scenarios:
เป็นไปตาม: เป็นไปตาม:
- [Backend Plan Section 4.2.10](../../docs/2_Backend_Plan_V1_4_5.md) - DocumentNumberingModule - ✅ [Requirements 3.11](../01-requirements/03.11-document-numbering.md) - Document Numbering Management (v1.5.0)
- [Requirements 3.11](../01-requirements/03.11-document-numbering.md) - Document Numbering - ✅ [Backend Plan Section 4.2.10](../../docs/2_Backend_Plan_V1_4_5.md) - DocumentNumberingModule
- [Requirements 6B](../../docs/2_Backend_Plan_V1_4_4.Phase6B.md) - Discipline Support - ✅ [Data Dictionary](../../docs/4_Data_Dictionary_V1_4_4.md) - Counter Tables
- ✅ [Security Best Practices](../02-architecture/security-architecture.md) - Rate Limiting, Audit Logging
--- ---
## Related ADRs ## Related ADRs
- [ADR-001: Unified Workflow Engine](./ADR-001-unified-workflow-engine.md) - Workflow triggers number generation - [ADR-001: Unified Workflow Engine](./ADR-001-unified-workflow-engine.md) - Workflow triggers number generation
- [ADR-005: Redis Usage Strategy](./ADR-005-redis-usage-strategy.md) - Redis lock implementation - [ADR-005: Redis Usage Strategy](./ADR-005-redis-usage-strategy.md) - Redis lock implementation details
- [ADR-006: Audit Logging Strategy](./ADR-006-audit-logging-strategy.md) - Comprehensive audit requirements
--- ---
## References ## References
- [Redlock Algorithm](https://redis.io/topics/distlock) - [Redlock Algorithm](https://redis.io/topics/distlock) - Distributed locking with Redis
- [TypeORM Optimistic Locking](https://typeorm.io/entities#version-column) - [TypeORM Optimistic Locking](https://typeorm.io/entities#version-column) - Version column usage
- [Distributed Lock Patterns](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) - [Distributed Lock Patterns](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) - Martin Kleppmann's analysis
- [Redis Persistence](https://redis.io/topics/persistence) - AOF and RDB strategies
- [Rate Limiting Patterns](https://redis.io/glossary/rate-limiting/) - Redis-based rate limiting
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-11-30 | Initial decision |
| 2.0 | 2025-12-02 | Updated with comprehensive error scenarios, monitoring, security, and all token types |

File diff suppressed because it is too large Load Diff