251202:1300
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -73,17 +73,24 @@
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"[sql]": {
|
||||
"editor.defaultFormatter": "mtxr.sqltools",
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true
|
||||
},
|
||||
"sqltools.codelensLanguages": ["sql", ""],
|
||||
"sqltools.codelensLanguages": ["sql"],
|
||||
"sqltools.format": {
|
||||
"language": "sql",
|
||||
"params": {
|
||||
"keywordCase": "upper", // ทำให้ INSERT, VALUES เป็นตัวใหญ่
|
||||
"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,
|
||||
|
||||
@@ -1,69 +1,296 @@
|
||||
# 3.11 Document Numbering Management (การจัดการเลขที่เอกสาร)
|
||||
|
||||
---
|
||||
|
||||
title: 'Functional Requirements: Document Numbering Management'
|
||||
version: 1.5.0
|
||||
status: first-draft
|
||||
owner: Nattanin Peancharoen
|
||||
last_updated: 2025-11-30
|
||||
last_updated: 2025-12-02
|
||||
related:
|
||||
|
||||
- specs/01-requirements/01-objectives.md
|
||||
- specs/01-requirements/02-architecture.md
|
||||
- specs/01-requirements/03-functional-requirements.md
|
||||
|
||||
- specs/01-requirements/01-objectives.md
|
||||
- specs/01-requirements/02-architecture.md
|
||||
- specs/01-requirements/03-functional-requirements.md
|
||||
- specs/04-data-dictionary/4_Data_Dictionary_V1_4_4.md
|
||||
---
|
||||
|
||||
## 3.11.1. วัตถุประสงค์
|
||||
|
||||
- ระบบต้องสามารถสร้างเลขที่เอกสาร (Running Number) ได้โดยอัตโนมัติและยืดหยุ่นสูง
|
||||
- ระบต้องสามารถกำหนด รูปแบบ(template) เลขที่เอกสารได้ สำหรับแต่ละโครงการ, ชนิดเอกสาร, ประเภทเอกสาร
|
||||
- ระบบต้องสามารถกำหนดรูปแบบ (template) เลขที่เอกสารได้ สำหรับแต่ละโครงการ, ชนิดเอกสาร, ประเภทเอกสาร
|
||||
- ระบบต้องรับประกัน Uniqueness ของเลขที่เอกสารในทุกสถานการณ์
|
||||
- ระบบต้องรองรับการทำงานแบบ concurrent ได้อย่างปลอดภัย
|
||||
|
||||
## 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
|
||||
- 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
|
||||
### ตัวอย่าง Counter Key
|
||||
|
||||
## 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)
|
||||
- Scenario 1: Redis Unavailable
|
||||
- Fallback เป็น database-only locking (pessimistic lock)
|
||||
- Log warning และแจ้ง ops team
|
||||
#### 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 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 ลดลง
|
||||
- Scenario 2: Lock Acquisition Timeout
|
||||
- Retry 5 ครั้งด้วย exponential backoff (1s, 2s, 4s, 8s, 16s)
|
||||
- หลัง 5 ครั้ง: Return error 503 "Service Temporarily Unavailable"
|
||||
- Frontend แสดง user-friendly message: "ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง"
|
||||
- Scenario 3: Version Conflict After Lock
|
||||
- Retry transaction อีก 2 ครั้ง
|
||||
- หากยังล้มเหลว: Log error พร้อม context และ return 409 Conflict
|
||||
- Frontend แสดง user-friendly message: "เลขที่เอกสารถูกเปลี่ยน กรุณาลองใหม่"
|
||||
- Monitoring:
|
||||
- Alert ถ้า lock acquisition failures > 5% ใน 5 นาที
|
||||
- Dashboard แสดง lock wait time percentiles
|
||||
|
||||
### 3.11.7.2. Scenario 2: Lock Acquisition Timeout
|
||||
|
||||
- **Retry**: 5 ครั้งด้วย exponential backoff
|
||||
- Attempt 1: wait 1s
|
||||
- Attempt 2: wait 2s
|
||||
- Attempt 3: wait 4s
|
||||
- Attempt 4: wait 8s
|
||||
- Attempt 5: wait 16s (รวม ~31 วินาที)
|
||||
- **Failure**: Return HTTP 503 "Service Temporarily Unavailable"
|
||||
- **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 ปี** (ตาม พ.ร.บ. ข้อมูลอิเล็กทรอนิกส์)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-002: Document Numbering Strategy
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2025-11-30
|
||||
**Date:** 2025-12-02
|
||||
**Decision Makers:** Development Team, System Architect
|
||||
**Related Documents:**
|
||||
|
||||
@@ -12,32 +12,34 @@
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS ต้องสร้างเลขที่เอกสารอัตโนมัติสำหรับ Correspondences และ RF
|
||||
|
||||
As โดยเลขที่เอกสารต้อง:
|
||||
LCBP3-DMS ต้องสร้างเลขที่เอกสารอัตโนมัติสำหรับ Correspondence, RFA, Transmittal และ Drawing โดยเลขที่เอกสารต้อง:
|
||||
|
||||
1. **Unique:** ไม่ซ้ำกันในระบบ
|
||||
2. **Sequential:** เรียงตามลำดับเวลา
|
||||
3. **Meaningful:** มีโครงสร้างที่อ่านเข้าใจได้ (เช่น `TEAM-RFA-STR-2025-0001`)
|
||||
4. **Configurable:** สามารถปรับรูปแบบได้ตาม Project/Organization
|
||||
3. **Meaningful:** มีโครงสร้างที่อ่านเข้าใจได้ (เช่น `LCBP3-C2-RFI-ROW-0029-A`)
|
||||
4. **Configurable:** สามารถปรับรูปแบบได้ตาม Project/Organization/Document Type
|
||||
5. **Concurrent-safe:** ป้องกัน Race Condition เมื่อมีหลาย Request พร้อมกัน
|
||||
|
||||
### Key Challenges
|
||||
|
||||
1. **Race Condition:** เมื่อมี 2+ requests พร้อมกัน อาจได้เลขเดียวกัน
|
||||
2. **Performance:** ต้องรวดเร็วแม้มี concurrent requests
|
||||
3. **Flexibility:** รองรับรูปแบบเลขที่หลากหลาย
|
||||
4. **Discipline Support:** เลขที่ต้องรวม Discipline Code (GEN, STR, ARC)
|
||||
2. **Performance:** ต้องรวดเร็วแม้มี concurrent requests (50-100 req/sec)
|
||||
3. **Flexibility:** รองรับรูปแบบเลขที่หลากหลายตามชนิดเอกสาร
|
||||
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
|
||||
|
||||
- **Data Integrity:** เลขที่ต้องไม่ซ้ำกันเด็ดขาด
|
||||
- **Performance:** Generate เลขที่ได้เร็ว (< 100ms)
|
||||
- **Scalability:** รองรับ concurrent requests สูง
|
||||
- **Data Integrity:** เลขที่ต้องไม่ซ้ำกันเด็ดขาด (Mission-Critical)
|
||||
- **Performance:** Generate เลขที่ได้เร็ว (<500ms normal, <2s p95, <5s p99)
|
||||
- **Scalability:** รองรับ 50-100 concurrent requests/second
|
||||
- **Maintainability:** ง่ายต่อการ Config และ Debug
|
||||
- **Flexibility:** รองรับรูปแบบที่หลากหลาย
|
||||
- **Flexibility:** รองรับ Template-based format สำหรับแต่ละ document type
|
||||
- **Auditability:** บันทึก history ของทุก generated number
|
||||
- **Security:** ป้องกัน abuse ด้วย rate limiting
|
||||
|
||||
---
|
||||
|
||||
@@ -56,8 +58,8 @@ As โดยเลขที่เอกสารต้อง:
|
||||
**Cons:**
|
||||
|
||||
- ❌ ไม่ Configurable (รูปแบบเลขที่ fixed)
|
||||
- ❌ ยากต่อการ Partition by Project/Type/Year
|
||||
- ❌ ไม่รองรับ Custom format (เช่น `TEAM-RFA-2025-0001`)
|
||||
- ❌ ยากต่อการ Partition by Project/Type/Discipline/Year
|
||||
- ❌ ไม่รองรับ Custom format (เช่น `LCBP3-RFA-2025-0001`)
|
||||
- ❌ Reset ตาม Year ทำได้ยาก
|
||||
|
||||
### Option 2: Application-Level Counter (Single Lock)
|
||||
@@ -83,11 +85,12 @@ As โดยเลขที่เอกสารต้อง:
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Guaranteed Uniqueness:** Double-layer protection
|
||||
- ✅ **Fast Performance:** Redis lock prevents most conflicts
|
||||
- ✅ **Audit Trail:** Counter history in database
|
||||
- ✅ **Fast Performance:** Redis lock prevents most conflicts (<500ms)
|
||||
- ✅ **Audit Trail:** Counter history + audit log in database
|
||||
- ✅ **Configurable Format:** Template-based generation
|
||||
- ✅ **Resilient:** Fallback to DB if Redis issues
|
||||
- ✅ **Partition Support:** Different counters per Project/Type/Discipline/Year
|
||||
- ✅ **Resilient:** Fallback to DB pessimistic lock if Redis unavailable
|
||||
- ✅ **Partition Support:** Different counters per Project/Type/SubType/Discipline/Year
|
||||
- ✅ **Transmittal Logic:** Support recipient-based counting
|
||||
|
||||
**Cons:**
|
||||
|
||||
@@ -107,8 +110,8 @@ As โดยเลขที่เอกสารต้อง:
|
||||
|
||||
1. **Mission-Critical:** เลขที่เอกสารต้องถูกต้อง 100% (ไม่ยอมรับการซ้ำ)
|
||||
2. **Performance + Safety:** Balance ระหว่างความเร็วและความปลอดภัย
|
||||
3. **Auditability:** มี Counter history ใน Database
|
||||
4. **Flexibility:** รองรับ Template-based format
|
||||
3. **Auditability:** มี Counter history + Audit log ใน Database
|
||||
4. **Flexibility:** รองรับ Template-based format สำหรับทุก document type
|
||||
5. **Resilience:** ถ้า Redis มีปัญหา ยัง Fallback ไปใช้ DB Lock ได้
|
||||
|
||||
---
|
||||
@@ -119,42 +122,139 @@ As โดยเลขที่เอกสารต้อง:
|
||||
|
||||
```sql
|
||||
-- Format Templates
|
||||
CREATE TABLE document_number_formats (
|
||||
CREATE TABLE document_number_configs (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
project_id INT NOT NULL,
|
||||
correspondence_type_id INT NOT NULL,
|
||||
format_template VARCHAR(255) NOT NULL,
|
||||
-- Example: '{ORG_CODE}-{TYPE_CODE}-{DISCIPLINE_CODE}-{YEAR}-{SEQ:4}'
|
||||
doc_type_id INT NOT NULL COMMENT 'Correspondence, RFA, Transmittal, Drawing',
|
||||
sub_type_id INT DEFAULT 0 COMMENT 'ประเภทย่อย (nullable, use 0 for fallback)',
|
||||
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,
|
||||
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 (correspondence_type_id) REFERENCES correspondence_types(id),
|
||||
UNIQUE KEY (project_id, correspondence_type_id)
|
||||
);
|
||||
FOREIGN KEY (doc_type_id) REFERENCES document_types(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
|
||||
CREATE TABLE document_number_counters (
|
||||
project_id INT NOT NULL,
|
||||
originator_organization_id INT NOT NULL,
|
||||
correspondence_type_id INT NOT NULL,
|
||||
discipline_id INT DEFAULT 0, -- 0 = no discipline
|
||||
current_year INT NOT NULL,
|
||||
doc_type_id INT NOT NULL,
|
||||
sub_type_id INT DEFAULT 0 COMMENT 'For Correspondence types, 0 = fallback',
|
||||
discipline_id INT DEFAULT 0 COMMENT 'For RFA/Drawing, 0 = fallback',
|
||||
recipient_type VARCHAR(20) DEFAULT NULL COMMENT 'For Transmittal: OWNER, CONTRACTOR, CONSULTANT, OTHER',
|
||||
year INT NOT NULL COMMENT 'ปี พ.ศ. หรือ ค.ศ. ตาม template',
|
||||
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,
|
||||
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 (originator_organization_id) REFERENCES organizations(id),
|
||||
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id),
|
||||
FOREIGN KEY (discipline_id) REFERENCES disciplines(id)
|
||||
);
|
||||
FOREIGN KEY (doc_type_id) REFERENCES document_types(id),
|
||||
INDEX idx_counter_lookup (project_id, doc_type_id, year)
|
||||
) 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
|
||||
// document-numbering.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
// File: backend/src/modules/document-numbering/document-numbering.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import Redlock from 'redlock';
|
||||
@@ -162,40 +262,91 @@ import Redis from 'ioredis';
|
||||
|
||||
interface NumberingContext {
|
||||
projectId: number;
|
||||
organizationId: number;
|
||||
typeId: number;
|
||||
docTypeId: number;
|
||||
subTypeId?: number;
|
||||
disciplineId?: number;
|
||||
recipientType?: 'OWNER' | 'CONTRACTOR' | 'CONSULTANT' | 'OTHER';
|
||||
year?: number;
|
||||
userId: number;
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocumentNumberingService {
|
||||
private readonly logger = new Logger(DocumentNumberingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DocumentNumberCounter)
|
||||
private counterRepo: Repository<DocumentNumberCounter>,
|
||||
@InjectRepository(DocumentNumberFormat)
|
||||
private formatRepo: Repository<DocumentNumberFormat>,
|
||||
@InjectRepository(DocumentNumberConfig)
|
||||
private configRepo: Repository<DocumentNumberConfig>,
|
||||
@InjectRepository(DocumentNumberAudit)
|
||||
private auditRepo: Repository<DocumentNumberAudit>,
|
||||
private redis: Redis,
|
||||
private redlock: Redlock
|
||||
) {}
|
||||
|
||||
async generateNextNumber(context: NumberingContext): Promise<string> {
|
||||
const year = context.year || new Date().getFullYear();
|
||||
const disciplineId = context.disciplineId || 0;
|
||||
const year = context.year || new Date().getFullYear() + 543; // พ.ศ.
|
||||
const subTypeId = context.subTypeId || 0; // Fallback for NULL
|
||||
const disciplineId = context.disciplineId || 0; // Fallback for NULL
|
||||
|
||||
// Step 1: Acquire Redis Distributed Lock
|
||||
const lockKey = `doc_num:${context.projectId}:${context.organizationId}:${context.typeId}:${disciplineId}:${year}`;
|
||||
const lock = await this.redlock.acquire([lockKey], 3000); // 3 second TTL
|
||||
// Build Redis lock key
|
||||
const lockKey = this.buildLockKey(
|
||||
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 {
|
||||
// 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
|
||||
let counter = await this.counterRepo.findOne({
|
||||
where: {
|
||||
project_id: context.projectId,
|
||||
originator_organization_id: context.organizationId,
|
||||
correspondence_type_id: context.typeId,
|
||||
doc_type_id: context.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
current_year: year,
|
||||
recipient_type: context.recipientType || null,
|
||||
year: year,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -203,19 +354,21 @@ export class DocumentNumberingService {
|
||||
if (!counter) {
|
||||
counter = this.counterRepo.create({
|
||||
project_id: context.projectId,
|
||||
originator_organization_id: context.organizationId,
|
||||
correspondence_type_id: context.typeId,
|
||||
doc_type_id: context.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
current_year: year,
|
||||
recipient_type: context.recipientType || null,
|
||||
year: year,
|
||||
last_number: 0,
|
||||
version: 0,
|
||||
});
|
||||
await this.counterRepo.save(counter);
|
||||
}
|
||||
|
||||
const currentVersion = counter.version;
|
||||
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
|
||||
.createQueryBuilder()
|
||||
.update(DocumentNumberCounter)
|
||||
@@ -225,54 +378,154 @@ export class DocumentNumberingService {
|
||||
})
|
||||
.where({
|
||||
project_id: context.projectId,
|
||||
originator_organization_id: context.organizationId,
|
||||
correspondence_type_id: context.typeId,
|
||||
doc_type_id: context.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
current_year: year,
|
||||
recipient_type: context.recipientType || null,
|
||||
year: year,
|
||||
version: currentVersion, // Optimistic lock check
|
||||
})
|
||||
.execute();
|
||||
|
||||
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
|
||||
const format = await this.getFormat(context.projectId, context.typeId);
|
||||
const formattedNumber = await this.formatNumber(format, {
|
||||
const config = await this.getConfig(
|
||||
context.projectId,
|
||||
context.docTypeId,
|
||||
subTypeId,
|
||||
disciplineId
|
||||
);
|
||||
|
||||
const formattedNumber = await this.formatNumber(config.template, {
|
||||
...context,
|
||||
year,
|
||||
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;
|
||||
|
||||
} finally {
|
||||
// Step 5: Release Redis lock
|
||||
await lock.release();
|
||||
// Step 6: Release Redis lock
|
||||
if (lock) {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async formatNumber(
|
||||
format: DocumentNumberFormat,
|
||||
data: any
|
||||
): Promise<string> {
|
||||
let result = format.format_template;
|
||||
|
||||
// Replace tokens
|
||||
private async formatNumber(template: string, data: any): Promise<string> {
|
||||
// Token replacement logic
|
||||
const tokens = {
|
||||
'{ORG_CODE}': await this.getOrgCode(data.organizationId),
|
||||
'{TYPE_CODE}': await this.getTypeCode(data.typeId),
|
||||
'{DISCIPLINE_CODE}': await this.getDisciplineCode(data.disciplineId),
|
||||
'{YEAR}': data.year.toString(),
|
||||
'{PROJECT}': await this.getProjectCode(data.projectId),
|
||||
'{ORG}': await this.getOrgCode(data.organizationId),
|
||||
'{TYPE}': await this.getTypeCode(data.docTypeId),
|
||||
'{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: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)) {
|
||||
result = result.replace(token, value);
|
||||
result = result.replace(new RegExp(token, 'g'), value);
|
||||
}
|
||||
|
||||
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
|
||||
sequenceDiagram
|
||||
participant Service as Correspondence Service
|
||||
participant Numbering as Numbering Service
|
||||
participant Client
|
||||
participant Service as Numbering Service
|
||||
participant Redis
|
||||
participant DB as MariaDB
|
||||
participant Audit
|
||||
|
||||
Service->>Numbering: generateNextNumber(context)
|
||||
Numbering->>Redis: ACQUIRE Lock (key)
|
||||
Client->>Service: generateNextNumber(context)
|
||||
Service->>Redis: ACQUIRE Lock (key, TTL=5s)
|
||||
|
||||
alt Lock Acquired
|
||||
Redis-->>Numbering: Lock Success
|
||||
Numbering->>DB: SELECT counter (with version)
|
||||
DB-->>Numbering: current_number, version
|
||||
Numbering->>DB: UPDATE counter SET last_number = X, version = version + 1<br/>WHERE version = old_version
|
||||
alt Redis Available
|
||||
Redis-->>Service: Lock Success
|
||||
Service->>DB: SELECT counter (with version)
|
||||
DB-->>Service: current_number, version
|
||||
Service->>DB: UPDATE counter SET last_number=X, version=version+1<br/>WHERE version=old_version
|
||||
|
||||
alt Update Success
|
||||
DB-->>Numbering: Success (1 row affected)
|
||||
Numbering->>Numbering: Format Number
|
||||
Numbering->>Redis: RELEASE Lock
|
||||
Numbering-->>Service: Document Number
|
||||
else Version Conflict
|
||||
DB-->>Numbering: Failed (0 rows affected)
|
||||
Numbering->>Redis: RELEASE Lock
|
||||
Numbering->>Numbering: Retry with Exponential Backoff
|
||||
alt Update Success (No Conflict)
|
||||
DB-->>Service: Success (1 row affected)
|
||||
Service->>Service: Format Number with Template
|
||||
Service->>Audit: Log generated number + metadata
|
||||
Service->>Redis: RELEASE Lock
|
||||
Service-->>Client: Document Number
|
||||
else Version Conflict (Scenario 3)
|
||||
DB-->>Service: Failed (0 rows affected)
|
||||
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
|
||||
else Lock Failed
|
||||
Redis-->>Numbering: Lock Timeout
|
||||
Numbering-->>Service: Error: Unable to acquire lock
|
||||
else Redis Unavailable (Scenario 1)
|
||||
Redis-->>Service: Connection Error
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
### Positive
|
||||
|
||||
1. ✅ **Zero Duplicate Risk:** Double-lock guarantees uniqueness
|
||||
2. ✅ **High Performance:** Redis lock prevents most DB conflicts (< 100ms)
|
||||
3. ✅ **Audit Trail:** All counters stored in database
|
||||
4. ✅ **Template-Based:** Easy to configure different formats
|
||||
5. ✅ **Partition Support:** Separate counters per Project/Type/Discipline/Year
|
||||
6. ✅ **Discipline Integration:** รองรับ Discipline Code ตาม Requirement 6B
|
||||
1. ✅ **Zero Duplicate Risk:** Double-lock + DB constraint guarantees uniqueness
|
||||
2. ✅ **High Performance:** Redis lock + optimistic locking (<500ms normal)
|
||||
3. ✅ **Complete Audit Trail:** All counters + generated numbers in database
|
||||
4. ✅ **Highly Configurable:** Template-based for all document types
|
||||
5. ✅ **Partition Support:** Separate counters per Project/Type/SubType/Discipline/Recipient/Year
|
||||
6. ✅ **Resilient:** Multiple fallback strategies for all failure scenarios
|
||||
7. ✅ **Transmittal Logic:** Supports recipient-based numbering
|
||||
8. ✅ **Security:** Rate limiting + authorization + audit logging
|
||||
|
||||
### Negative
|
||||
|
||||
1. ❌ **Complexity:** Requires Redis + Database coordination
|
||||
2. ❌ **Dependencies:** Requires both Redis and DB healthy
|
||||
3. ❌ **Retry Logic:** May retry on optimistic lock conflicts
|
||||
4. ❌ **Monitoring:** Need to monitor lock acquisition times
|
||||
1. ❌ **Complexity:** Requires coordination between Redis and Database
|
||||
2. ❌ **Dependencies:** Requires both Redis and DB healthy for optimal performance
|
||||
3. ❌ **Retry Logic:** May retry causing delays under high contention
|
||||
4. ❌ **Monitoring Overhead:** Need comprehensive monitoring for all scenarios
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Redis Dependency:** Use Redis Persistence (AOF) และ Replication
|
||||
- **Complexity:** Encapsulate logic in `DocumentNumberingService`
|
||||
- **Retry:** Exponential backoff with max 3 retries
|
||||
- **Monitoring:** Track lock wait times และ conflict rates
|
||||
- **Redis Dependency:** Use Redis Persistence (AOF) + Replication + Fallback to DB
|
||||
- **Complexity:** Encapsulate all logic in `DocumentNumberingService`
|
||||
- **Retry Delays:** Exponential backoff limits max delay time
|
||||
- **Monitoring:** Automated dashboards + alerting for all critical metrics
|
||||
|
||||
---
|
||||
|
||||
@@ -346,12 +746,13 @@ sequenceDiagram
|
||||
```typescript
|
||||
describe('DocumentNumberingService - Concurrent Generation', () => {
|
||||
it('should generate unique numbers for 100 concurrent requests', async () => {
|
||||
const context = {
|
||||
const context: NumberingContext = {
|
||||
projectId: 1,
|
||||
organizationId: 1,
|
||||
typeId: 1,
|
||||
disciplineId: 2, // STR
|
||||
year: 2025,
|
||||
docTypeId: 2, // RFA
|
||||
disciplineId: 3, // STR
|
||||
year: 2568,
|
||||
userId: 1,
|
||||
ipAddress: '192.168.1.1',
|
||||
};
|
||||
|
||||
const promises = Array(100)
|
||||
@@ -364,22 +765,61 @@ describe('DocumentNumberingService - Concurrent Generation', () => {
|
||||
const unique = new Set(results);
|
||||
expect(unique.size).toBe(100);
|
||||
|
||||
// Check sequential
|
||||
const numbers = results.map((r) => parseInt(r.split('-').pop()));
|
||||
const sorted = [...numbers].sort((a, b) => a - b);
|
||||
expect(numbers.every((n, i) => sorted.includes(n))).toBe(true);
|
||||
// Check format
|
||||
results.forEach(num => {
|
||||
expect(num).toMatch(/^LCBP3-C2-RFI-STR-\d{4}-A$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use correct format template', async () => {
|
||||
it('should use correct format for Transmittal To Owner', async () => {
|
||||
const number = await service.generateNextNumber({
|
||||
projectId: 1,
|
||||
organizationId: 3, // TEAM
|
||||
typeId: 1, // RFA
|
||||
disciplineId: 2, // STR
|
||||
year: 2025,
|
||||
docTypeId: 3, // Transmittal
|
||||
recipientType: 'OWNER',
|
||||
year: 2568,
|
||||
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
|
||||
|
||||
```yaml
|
||||
# Artillery configuration
|
||||
# artillery.yml
|
||||
config:
|
||||
target: 'http://localhost:3000'
|
||||
phases:
|
||||
- duration: 60
|
||||
arrivalRate: 50 # 50 requests/second
|
||||
name: 'Normal Load'
|
||||
- duration: 30
|
||||
arrivalRate: 100 # 100 requests/second
|
||||
name: 'Peak Load'
|
||||
|
||||
scenarios:
|
||||
- name: 'Generate Document Numbers'
|
||||
- name: 'Generate Document Numbers - RFA'
|
||||
weight: 40
|
||||
flow:
|
||||
- post:
|
||||
url: '/correspondences'
|
||||
url: '/api/v1/rfa'
|
||||
json:
|
||||
title: 'Load Test {{ $randomString() }}'
|
||||
project_id: 1
|
||||
type_id: 1
|
||||
discipline_id: 2
|
||||
doc_type_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
|
||||
- [Requirements 6B](../../docs/2_Backend_Plan_V1_4_4.Phase6B.md) - Discipline Support
|
||||
- ✅ [Requirements 3.11](../01-requirements/03.11-document-numbering.md) - Document Numbering Management (v1.5.0)
|
||||
- ✅ [Backend Plan Section 4.2.10](../../docs/2_Backend_Plan_V1_4_5.md) - DocumentNumberingModule
|
||||
- ✅ [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
|
||||
|
||||
- [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
|
||||
|
||||
- [Redlock Algorithm](https://redis.io/topics/distlock)
|
||||
- [TypeORM Optimistic Locking](https://typeorm.io/entities#version-column)
|
||||
- [Distributed Lock Patterns](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html)
|
||||
- [Redlock Algorithm](https://redis.io/topics/distlock) - Distributed locking with Redis
|
||||
- [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) - 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
Reference in New Issue
Block a user