251217:1704 Docunment Number: Update to 1.6.2
Some checks are pending
Spec Validation / validate-markdown (push) Waiting to run
Spec Validation / validate-diagrams (push) Waiting to run
Spec Validation / check-todos (push) Waiting to run

This commit is contained in:
admin
2025-12-17 17:04:06 +07:00
parent 48ed74a27b
commit aaa5da3ec1
121 changed files with 8072 additions and 2103 deletions

View File

@@ -5,5 +5,6 @@ backend/dist/
frontend/dist/
backend/build/
frontend/build/
docs/backup/
.git/
*.log

View File

@@ -1,4 +1,6 @@
node_modules
backend/node_modules/
frontend/node_modules/
dist
build
*.min.js

View File

@@ -51,10 +51,12 @@ export class DocumentNumberingService implements OnModuleInit {
onModuleInit() {
const host = this.configService.get<string>('REDIS_HOST', 'localhost');
const port = this.configService.get<number>('REDIS_PORT', 6379);
const password = this.configService.get<string>('REDIS_PASSWORD');
this.redisClient = new Redis({
host,
port,
password,
retryStrategy: (times) => Math.min(times * 50, 2000),
maxRetriesPerRequest: 3,
});

View File

@@ -0,0 +1,682 @@
# Document Numbering Requirements
**Version**: 1.6.1
**Last Updated**: 2025-01-16
**Status**: draft
**Related ADRs**: ADR-018-document-numbering-strategy
---
## 1. Overview
### 1.1 Purpose
ระบบ Document Numbering สำหรับสร้างเลขที่เอกสารอัตโนมัติที่มีความเป็นเอกลักษณ์ (unique) และสามารถติดตามได้ (traceable) สำหรับเอกสารทุกประเภทในระบบ LCBP3-DMS
### 1.2 Scope
- Auto-generation ของเลขที่เอกสารตามรูปแบบที่กำหนด
- Manual override สำหรับการ import เอกสารเก่า
- Cancelled number handling (ไม่ reuse)
- Void & Replace pattern สำหรับการแทนที่เอกสาร
- Distributed locking เพื่อป้องกัน race condition
- Complete audit trail สำหรับทุก operation
### 1.3 Document Types Supported
- Correspondences (COR)
- Request for Approvals (RFA)
- Contract Drawings (CD)
- Shop Drawings (SD)
- Transmittals (TRN)
- Circulation Sheets (CIR)
---
## 2. Functional Requirements
### 2.1 Auto Number Generation
#### FR-DN-001: Generate Sequential Number
**Priority**: CRITICAL
**Status**: Required
**Description**:
ระบบต้องสามารถสร้างเลขที่เอกสารอัตโนมัติตามลำดับ (sequential) โดยไม่ซ้ำกัน
**Acceptance Criteria**:
- เลขที่เอกสารต้องเป็น unique ใน scope ที่กำหนด
- ต้องเพิ่มขึ้นทีละ 1 (increment by 1)
- ต้องรองรับ concurrent requests โดยไม่มีเลขที่ซ้ำ
- Response time < 100ms (p95)
**Example**:
```
COR-00001-2025
COR-00002-2025
COR-00003-2025
```
---
#### FR-DN-002: Configurable Number Format
**Priority**: HIGH
**Status**: Required
**Description**:
ระบบต้องรองรับการกำหนดรูปแบบเลขที่เอกสารที่หลากหลาย
**Format Tokens**:
- `{PREFIX}` - คำนำหน้าตามประเภทเอกสาร (e.g., COR, RFA)
- `{YYYY}` - ปี 4 หลัก (e.g., 2025)
- `{YY}` - ปี 2 หลัก (e.g., 25)
- `{MM}` - เดือน 2 หลัก (e.g., 01-12)
- `{SEQ:n}` - sequence number ความยาว n หลัก (e.g., {SEQ:5} = 00001)
- `{PROJECT}` - รหัสโครงการ
- `{CONTRACT}` - รหัสสัญญา
**Acceptance Criteria**:
- รองรับ format tokens ที่ระบุ
- Admin สามารถกำหนด format ผ่าน UI ได้
- Validate format ก่อน save
- แสดง preview ของเลขที่ที่จะถูกสร้าง
**Examples**:
```typescript
// Correspondence format
"COR-{YYYY}-{SEQ:5}"
COR-2025-00001
// RFA format with project
"RFA-{PROJECT}-{YYYY}{MM}-{SEQ:4}"
RFA-LCBP3-202501-0001
// Drawing format
"{CONTRACT}-CD-{SEQ:6}"
C001-CD-000001
```
---
#### FR-DN-003: Scope-based Sequences
**Priority**: HIGH
**Status**: Required
**Description**:
ระบบต้องรองรับการสร้าง sequence ที่แยกตาม scope ที่ต่างกัน
**Scopes**:
1. **Global**: Sequence ระดับระบบทั้งหมด
2. **Project**: Sequence แยกตามโครงการ
3. **Contract**: Sequence แยกตามสัญญา
4. **Yearly**: Sequence reset ทุกปี
5. **Monthly**: Sequence reset ทุกเดือน
**Acceptance Criteria**:
- เลขที่ไม่ซ้ำภายใน scope เดียวกัน
- Scope ที่ต่างกันสามารถมีเลขที่เดียวกันได้
- Support multiple active scopes
**Example**:
```
Project A: COR-A-2025-00001, COR-A-2025-00002
Project B: COR-B-2025-00001, COR-B-2025-00002
Yearly Reset:
COR-2024-00999 (Dec 2024)
COR-2025-00001 (Jan 2025)
```
---
### 2.2 Manual Override
#### FR-DN-004: Manual Number Assignment
**Priority**: HIGH
**Status**: Required
**Description**:
ระบบต้องรองรับการกำหนดเลขที่เอกสารด้วยตนเอง (manual override)
**Use Cases**:
1. Import เอกสารเก่าจากระบบเดิม
2. External documents จาก client/consultant
3. Correction หลังพบความผิดพลาด
**Acceptance Criteria**:
- ตรวจสอบ duplicate ก่อน save
- Validate format ตามรูปแบบที่กำหนด
- Auto-update sequence counter ถ้าเลขที่สูงกว่า current
- บันทึก audit log ว่าเป็น manual override
- ต้องมีสิทธิ์ Admin ขึ้นไปเท่านั้น
**Validation Rules**:
```typescript
interface ManualNumberValidation {
format_match: boolean; // ตรง format หรือไม่
not_duplicate: boolean; // ไม่ซ้ำ
in_valid_range: boolean; // อยู่ในช่วงที่กำหนด
permission_granted: boolean; // มีสิทธิ์
}
```
---
#### FR-DN-005: Bulk Import Support
**Priority**: MEDIUM
**Status**: Required
**Description**:
ระบบต้องรองรับการ import เอกสารหลายรายการพร้อมกัน
**Acceptance Criteria**:
- รองรับไฟล์ CSV/Excel
- Validate ทุกรายการก่อน import
- แสดง preview ก่อน confirm
- Rollback ทั้งหมดถ้ามีรายการใดผิดพลาด (transactional)
- Auto-update sequence counters หลัง import
- Generate import report
**CSV Format**:
```csv
document_type,document_number,created_at,metadata
COR,COR-2024-00001,2024-01-01,{"imported":true}
COR,COR-2024-00002,2024-01-05,{"imported":true}
```
---
### 2.3 Cancelled & Void Handling
#### FR-DN-006: Skip Cancelled Numbers
**Priority**: HIGH
**Status**: Required
**Description**:
เลขที่เอกสารที่ถูกยกเลิกต้องไม่ถูก reuse
**Rationale**:
- รักษา audit trail ที่ชัดเจน
- ป้องกันความสับสน
- Legal compliance
**Acceptance Criteria**:
- Cancelled number ยังคงอยู่ในฐานข้อมูลพร้อม status
- ระบบข้าม (skip) cancelled number เมื่อสร้างเลขที่ใหม่
- บันทึกเหตุผลการยกเลิก
- แสดง cancelled numbers ใน audit trail
**Example Timeline**:
```
2025-00001 ✅ ACTIVE (created 2025-01-01)
2025-00002 ❌ CANCELLED (created 2025-01-02, cancelled 2025-01-03)
2025-00003 ✅ ACTIVE (created 2025-01-04)
```
---
#### FR-DN-007: Void and Replace
**Priority**: HIGH
**Status**: Required
**Description**:
ระบบต้องรองรับการ void เอกสารและสร้างเอกสารใหม่แทน
**Workflow**:
1. User เลือกเอกสารที่ต้องการ void
2. ระบุเหตุผล (required)
3. ระบบเปลี่ยน status เอกสารเดิมเป็น VOID
4. สร้างเอกสารใหม่ด้วยเลขที่ใหม่
5. Link เอกสารใหม่กับเดิม (voided_from_id)
**Acceptance Criteria**:
- เอกสารเดิม status = VOID (ไม่ลบ)
- เอกสารใหม่ได้เลขที่ต่อเนื่องจาก sequence
- มี reference link ระหว่างเอกสาร
- บันทึก void reason
- แสดง void history chain (A→B→C)
**Database Relationship**:
```sql
-- Original document
id: 100
document_number: COR-2025-00005
status: VOID
void_reason: "ข้อมูลผิด"
voided_at: 2025-01-10
-- Replacement document
id: 101
document_number: COR-2025-00006
status: ACTIVE
voided_from_id: 100
```
---
### 2.4 Concurrency & Performance
#### FR-DN-008: Prevent Race Conditions
**Priority**: CRITICAL
**Status**: Required
**Description**:
ระบบต้องป้องกันการสร้างเลขที่ซ้ำเมื่อมีการ request พร้อมกัน
**Solution**:
- Distributed locking (Redlock)
- Database pessimistic locking
- Two-phase commit pattern
**Acceptance Criteria**:
- Zero duplicate numbers ภายใต้ concurrent load (1000 req/s)
- Lock acquisition time < 50ms (avg)
- Automatic retry on lock failure (max 3 times)
- Timeout handling (30 seconds)
**Load Test Requirements**:
```bash
# Must pass without duplicates
concurrent_users: 100
requests_per_second: 500
test_duration: 5 minutes
expected_duplicates: 0
```
---
#### FR-DN-009: Two-Phase Commit
**Priority**: HIGH
**Status**: Required
**Description**:
ใช้ Two-phase commit pattern เพื่อความสมบูรณ์ของข้อมูล
**Phase 1: Reserve**
- ล็อกเลขที่และ reserve ไว้ชั่วคราว
- Set TTL 5 นาที
- Return reservation token
**Phase 2: Confirm or Cancel**
- Confirm: บันทึกลงฐานข้อมูลถาวร
- Cancel: คืน lock และ reservation
**Acceptance Criteria**:
- Reservation ต้อง expire หลัง 5 นาที
- Auto-cleanup expired reservations
- Support explicit cancel
- Idempotent confirmation
**API Flow**:
```typescript
// Phase 1
const { token, number } = await reserveNumber({
document_type: 'COR',
project_id: 1
});
// Do some work...
// Phase 2
await confirmReservation(token);
// OR
await cancelReservation(token);
```
---
### 2.5 Monitoring & Audit
#### FR-DN-010: Complete Audit Trail
**Priority**: HIGH
**Status**: Required
**Description**:
บันทึกทุก operation ที่เกิดขึ้นกับเลขที่เอกสาร
**Events to Log**:
- Number reserved
- Number confirmed
- Number cancelled
- Manual override
- Void document
- Sequence adjusted
- Format changed
**Audit Fields**:
```typescript
interface AuditLog {
id: number;
operation: string;
document_type: string;
document_number: string;
old_value?: any;
new_value?: any;
user_id: number;
ip_address: string;
user_agent: string;
timestamp: Date;
metadata: Record<string, any>;
}
```
**Acceptance Criteria**:
- Log ทุก operation
- Searchable by user, date, type
- Export to CSV
- Retain for 7 years
---
#### FR-DN-011: Metrics & Alerting
**Priority**: MEDIUM
**Status**: Required
**Description**:
แสดงสถิติและส่ง alert เมื่อเกิดปัญหา
**Metrics**:
- Sequence utilization (% of max)
- Average lock wait time
- Failed lock attempts
- Numbers generated per day
- Manual overrides per day
**Alerts**:
- Sequence >90% used (WARNING)
- Sequence >95% used (CRITICAL)
- Lock wait time >1s (WARNING)
- Redis unavailable (CRITICAL)
- High error rate (WARNING)
**Acceptance Criteria**:
- Real-time dashboard (Grafana)
- Email/LINE notifications
- Alert history tracking
- Configurable thresholds
---
## 3. Non-Functional Requirements
### 3.1 Performance
#### NFR-DN-001: Response Time
- Number generation: <100ms (p95)
- Lock acquisition: <50ms (avg)
- Bulk import: <5s per 100 records
#### NFR-DN-002: Throughput
- Support >500 req/s
- Scale horizontally (add Redis nodes)
### 3.2 Reliability
#### NFR-DN-003: Availability
- System uptime: 99.9%
- Graceful degradation (fallback to DB-only)
- Auto-recovery from Redis failure
#### NFR-DN-004: Data Integrity
- Zero duplicate numbers (100% guarantee)
- ACID transactions
- Backup & restore procedures
### 3.3 Security
#### NFR-DN-005: Access Control
- Admin only: Format configuration, sequence adjustment
- Manager+: Manual override, void document
- User: Auto-generate only
- Audit all operations
#### NFR-DN-006: Data Protection
- Encrypt sensitive data (audit logs)
- Secure Redis connections (TLS)
- Rate limiting (100 req/min per user)
### 3.4 Scalability
#### NFR-DN-007: Capacity Planning
- Support 10,000 documents/day
- Store 10M+ historical numbers
- Archive old audit logs (>2 years)
### 3.5 Maintainability
#### NFR-DN-008: Code Quality
- Unit test coverage: >70%
- Integration test coverage: >50%
- E2E test coverage: >20 critical paths
- Documentation: Complete API docs
---
## 4. Business Rules
### BR-DN-001: Sequence Scope Rules
- Correspondence: Project-level + Yearly reset
- RFA: Contract-level + Yearly reset
- Drawings: Contract-level + No reset
- Transmittal: Project-level + Monthly reset
### BR-DN-002: Number Format Rules
- Min length: 10 characters
- Max length: 50 characters
- Must include {SEQ} token
- Must be ASCII only (no Thai/Chinese)
### BR-DN-003: Manual Override Rules
- Only for document_types with allow_manual_override=true
- Must validate format
- Must check duplicate
- Requires Admin permission
### BR-DN-004: Void Rules
- Can only void ACTIVE documents
- Cannot void already-VOID documents
- Must provide reason (min 10 chars)
- Replacement is optional
---
## 5. Data Model
### 5.1 Numbering Configuration
```sql
CREATE TABLE document_numbering_configs (
id INT PRIMARY KEY AUTO_INCREMENT,
document_type VARCHAR(50) NOT NULL,
format VARCHAR(200) NOT NULL,
scope ENUM('GLOBAL','PROJECT','CONTRACT','YEARLY','MONTHLY'),
allow_manual_override BOOLEAN DEFAULT FALSE,
max_value INT DEFAULT 999999,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY (document_type, scope)
);
```
### 5.2 Sequence Counter
```sql
CREATE TABLE document_numbering_sequences (
id INT PRIMARY KEY AUTO_INCREMENT,
config_id INT NOT NULL,
scope_value VARCHAR(50), -- project_id, contract_id, year, etc.
current_value INT DEFAULT 0,
last_used_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (config_id) REFERENCES document_numbering_configs(id),
UNIQUE KEY (config_id, scope_value)
);
```
### 5.3 Audit Log
```sql
CREATE TABLE document_numbering_audit_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
operation VARCHAR(50) NOT NULL,
document_type VARCHAR(50),
document_number VARCHAR(50),
old_value TEXT,
new_value TEXT,
user_id INT NOT NULL,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata JSON,
INDEX idx_document_number (document_number),
INDEX idx_user_id (user_id),
INDEX idx_timestamp (timestamp)
);
```
---
## 6. API Specifications
### 6.1 Reserve Number
```http
POST /api/document-numbering/reserve
Content-Type: application/json
{
"document_type": "COR",
"project_id": 1,
"contract_id": null,
"metadata": {}
}
Response 201:
{
"token": "uuid-v4",
"document_number": "COR-2025-00042",
"expires_at": "2025-01-16T10:30:00Z"
}
```
### 6.2 Confirm Reservation
```http
POST /api/document-numbering/confirm
Content-Type: application/json
{
"token": "uuid-v4"
}
Response 200:
{
"document_number": "COR-2025-00042",
"confirmed_at": "2025-01-16T10:25:00Z"
}
```
### 6.3 Manual Override
```http
POST /api/document-numbering/manual
Content-Type: application/json
Authorization: Bearer <admin-token>
{
"document_type": "COR",
"document_number": "COR-2024-99999",
"reason": "Import from legacy system",
"skip_validation": false
}
Response 201:
{
"document_number": "COR-2024-99999",
"is_manual": true,
"created_at": "2025-01-16T10:25:00Z"
}
```
---
## 7. Testing Requirements
### 7.1 Unit Tests
- Format parsing and validation
- Sequence increment logic
- Manual override validation
- Scope resolution
### 7.2 Integration Tests
- Redis locking mechanism
- Database transactions
- Two-phase commit flow
- Bulk import
### 7.3 Load Tests
- Concurrent number generation (1000 req/s)
- Lock contention under load
- Redis failover scenarios
- Database connection pool exhaustion
### 7.4 E2E Tests
- Complete document creation flow
- Void and replace workflow
- Bulk import with validation
- Admin configuration UI
---
## 8. Migration Plan
### 8.1 Legacy Data Import
1. Export existing document numbers from old system
2. Validate format and detect duplicates
3. Bulk import using manual override API
4. Update sequence counters to max values
5. Verify data integrity
### 8.2 Rollout Strategy
- Week 1-2: Deploy to staging, test with dummy data
- Week 3: Deploy to production, enable for test project
- Week 4: Enable for all projects
- Week 5+: Monitor and optimize
---
## 9. Success Criteria
### 9.1 Functional Success
- ✅ All FRs implemented and tested
- ✅ Zero duplicate numbers in production
- ✅ Migration of 50,000+ legacy documents
- ✅ UAT approved by stakeholders
### 9.2 Performance Success
- ✅ Response time <100ms (p95)
- ✅ Throughput >500 req/s
- ✅ Lock acquisition <50ms (avg)
- ✅ Zero downtime during deployment
### 9.3 Business Success
- ✅ Document creation speed +30%
- ✅ Manual numbering errors -80%
- ✅ User satisfaction >4.5/5
- ✅ System stability >99.9%
---
## 10. Appendix
### 10.1 Glossary
- **Sequence**: ลำดับตัวเลขที่เพิ่มขึ้นอัตโนมัติ
- **Scope**: ขอบเขตที่ sequence แยกตาม (project, contract, etc.)
- **Token**: Format placeholder (e.g., {YYYY}, {SEQ})
- **Redlock**: Distributed locking algorithm สำหรับ Redis
### 10.2 References
- [ADR-018: Document Numbering Strategy](../05-decisions/adr-018-document-numbering.md)
- [Two-Phase Commit Pattern](https://en.wikipedia.org/wiki/Two-phase_commit_protocol)
- [Redlock Algorithm](https://redis.io/docs/manual/patterns/distributed-locks/)
---
**Approval Sign-off**:
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Product Owner | ___________ | _______ | _________ |
| Tech Lead | ___________ | _______ | _________ |
| QA Lead | ___________ | _______ | _________ |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,813 @@
# Document Numbering Implementation Guide
---
title: 'Implementation Guide: Document Numbering System'
version: 1.6.1
status: implemented
owner: Development Team
last_updated: 2025-12-16
related:
- specs/01-requirements/03.11-document-numbering.md
- specs/04-operations/document-numbering-operations.md
---
## Overview
เอกสารนี้อธิบาย implementation details สำหรับระบบ Document Numbering ตาม requirements ใน [03.11-document-numbering.md](file:///e:/np-dms/lcbp3/specs/01-requirements/03.11-document-numbering.md)
## Technology Stack
- **Backend Framework**: NestJS 10.x
- **ORM**: TypeORM 0.3.x
- **Database**: MariaDB 11.8
- **Cache/Lock**: Redis 7.x + Redlock
- **Message Queue**: BullMQ
- **Monitoring**: Prometheus + Grafana
## 1. Database Implementation
### 1.1. Counter Table Schema
```sql
CREATE TABLE document_number_formats (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
correspondence_type_id INT NULL, -- NULL indicates default format for the project
format_template VARCHAR(100) NOT NULL,
reset_sequence_yearly TINYINT(1) DEFAULT 1,
description VARCHAR(255),
created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY idx_unique_project_type (project_id, correspondence_type_id),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE
);
CREATE TABLE document_number_counters (
project_id INT NOT NULL,
originator_organization_id INT NOT NULL,
recipient_organization_id INT NULL,
correspondence_type_id INT NOT NULL,
sub_type_id INT DEFAULT 0,
rfa_type_id INT DEFAULT 0,
discipline_id INT DEFAULT 0,
current_year INT NOT NULL,
version INT DEFAULT 0 NOT NULL,
last_number INT DEFAULT 0,
PRIMARY KEY (
project_id,
originator_organization_id,
COALESCE(recipient_organization_id, 0),
correspondence_type_id,
sub_type_id,
rfa_type_id,
discipline_id,
current_year
),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (originator_organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (recipient_organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE,
INDEX idx_counter_lookup (project_id, correspondence_type_id, current_year),
INDEX idx_counter_org (originator_organization_id, current_year),
CONSTRAINT chk_last_number_positive CHECK (last_number >= 0),
CONSTRAINT chk_current_year_valid CHECK (current_year BETWEEN 2020 AND 2100)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='ตารางเก็บ Running Number Counters';
```
### 1.2. Audit Table Schema
```sql
CREATE TABLE document_number_audit (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
document_id INT NULL COMMENT 'FK to documents (NULL initially, updated after doc creation)',
generated_number VARCHAR(100) NOT NULL,
counter_key JSON NOT NULL COMMENT 'Counter key used (JSON format)',
template_used VARCHAR(200) NOT NULL,
user_id INT NULL COMMENT 'FK to users (Allow NULL for system generation)',
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_success BOOLEAN DEFAULT TRUE COMMENT 'Track success/failure status',
-- Performance & Error Tracking
retry_count INT DEFAULT 0,
lock_wait_ms INT COMMENT 'Lock acquisition time in milliseconds',
total_duration_ms INT COMMENT 'Total generation time',
fallback_used ENUM('NONE', 'DB_LOCK', 'RETRY') DEFAULT 'NONE',
INDEX idx_document_id (document_id),
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at),
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB COMMENT='Document Number Generation Audit Trail';
```
### 1.3. Error Log Table
```sql
CREATE TABLE document_number_errors (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
error_type ENUM(
'LOCK_TIMEOUT',
'VERSION_CONFLICT',
'DB_ERROR',
'REDIS_ERROR',
'VALIDATION_ERROR'
) NOT NULL,
error_message TEXT,
stack_trace TEXT,
context_data JSON COMMENT 'Request context (user, project, etc.)',
user_id INT,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP NULL,
INDEX idx_error_type (error_type),
INDEX idx_created_at (created_at),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB COMMENT='Document Numbering Error Log';
```
## 2. NestJS Implementation
### 2.1. Module Structure
```
src/modules/document-numbering/
├── document-numbering.module.ts
├── controllers/
│ └── document-numbering.controller.ts
├── services/
│ ├── document-numbering.service.ts
│ ├── document-numbering-lock.service.ts
│ ├── counter.service.ts
│ ├── template.service.ts
│ └── audit.service.ts
├── entities/
│ ├── document-number-counter.entity.ts
│ ├── document-number-audit.entity.ts
│ └── document-number-error.entity.ts
├── dto/
│ ├── generate-number.dto.ts
│ └── update-template.dto.ts
├── validators/
│ └── template.validator.ts
├── jobs/
│ └── counter-reset.job.ts
└── metrics/
└── metrics.service.ts
```
### 2.2. Number Generation Process
#### 2.2.1. Resolve Format Template:
* Query document_number_formats by project_id + type_id.
* If no result, query by project_id + NULL (Default Project Format).
* If still no result, apply System Default Template: `{ORG}-{RECIPIENT}-{SEQ:4}-{YEAR:BE}`.
* Determine resetSequenceYearly flag from the found format (default: true)
#### 2.2.2. Determine Counter Key:
* If resetSequenceYearly is True: Use Current Year (e.g., 2025).
* If resetSequenceYearly is False: Use 0 (Continuous).
* Use type_id from the resolved format (Specific ID or NULL).
#### 2.2.3. Generate Number:
* Use format template to generate number.
* Replace tokens with actual values:
* {PROJECT} -> Project Code
* {ORG} -> Originator Organization Code
* {RECIPIENT} -> Recipient Organization Code
* {TYPE} -> Type Code
* {YEAR} -> Current Year
* {SEQ} -> Sequence Number
* {REV} -> Revision Number
#### 2.2.4. Validate Number:
* Check if generated number is unique.
* If not unique, increment sequence and retry.
#### 2.2.5. Update Counter:
* Update document_number_counters with new sequence.
#### 2.2.6. Generate Audit Record:
* Create audit record with:
* Generated number
* Counter key used
* Template used
* User ID
* IP Address
* User Agent
#### 2.2.7. Return Generated Number:
* Return generated number to caller.
### 2.3. TypeORM Entity
```typescript
// File: src/modules/document-numbering/entities/document-number-counter.entity.ts
import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm';
@Entity('document_number_counters')
export class DocumentNumberCounter {
@PrimaryColumn({ name: 'project_id' })
projectId: number;
@PrimaryColumn({ name: 'originator_organization_id' })
originatorOrganizationId: number;
@PrimaryColumn({ name: 'recipient_organization_id', nullable: true })
recipientOrganizationId: number | null;
@PrimaryColumn({ name: 'correspondence_type_id' })
correspondenceTypeId: number;
@PrimaryColumn({ name: 'sub_type_id', default: 0 })
subTypeId: number;
@PrimaryColumn({ name: 'rfa_type_id', default: 0 })
rfaTypeId: number;
@PrimaryColumn({ name: 'discipline_id', default: 0 })
disciplineId: number;
@PrimaryColumn({ name: 'current_year' })
currentYear: number;
@VersionColumn({ name: 'version' })
version: number;
@Column({ name: 'last_number', default: 0 })
lastNumber: number;
}
```
### 2.4. Redis Lock Service
```typescript
// File: src/modules/document-numbering/services/document-numbering-lock.service.ts
import { Injectable, Logger } from '@nestjs/common';
import Redlock from 'redlock';
import { InjectRedis } from '@nestjs-modules/ioredis';
import { Redis } from 'ioredis';
interface CounterKey {
projectId: number;
originatorOrgId: number;
recipientOrgId: number | null;
correspondenceTypeId: number;
subTypeId: number;
rfaTypeId: number;
disciplineId: number;
year: number;
}
@Injectable()
export class DocumentNumberingLockService {
private readonly logger = new Logger(DocumentNumberingLockService.name);
private redlock: Redlock;
constructor(@InjectRedis() private readonly redis: Redis) {
this.redlock = new Redlock([redis], {
driftFactor: 0.01,
retryCount: 5,
retryDelay: 100,
retryJitter: 50,
});
}
async acquireLock(counterKey: CounterKey): Promise<Redlock.Lock> {
const lockKey = this.buildLockKey(counterKey);
const ttl = 5000; // 5 วินาที
try {
const lock = await this.redlock.acquire([lockKey], ttl);
this.logger.debug(`Acquired lock: ${lockKey}`);
return lock;
} catch (error) {
this.logger.error(`Failed to acquire lock: ${lockKey}`, error);
throw error;
}
}
async releaseLock(lock: Redlock.Lock): Promise<void> {
try {
await lock.release();
this.logger.debug('Released lock');
} catch (error) {
this.logger.warn('Failed to release lock (may have expired)', error);
}
}
private buildLockKey(key: CounterKey): string {
return `lock:docnum:${key.projectId}:${key.originatorOrgId}:` +
`${key.recipientOrgId ?? 0}:${key.correspondenceTypeId}:` +
`${key.subTypeId}:${key.rfaTypeId}:${key.disciplineId}:${key.year}`;
}
}
```
### 2.4. Counter Service
```typescript
// File: src/modules/document-numbering/services/counter.service.ts
import { Injectable, ConflictException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { DocumentNumberCounter } from '../entities/document-number-counter.entity';
import { OptimisticLockVersionMismatchError } from 'typeorm';
@Injectable()
export class CounterService {
private readonly logger = new Logger(CounterService.name);
constructor(
@InjectRepository(DocumentNumberCounter)
private counterRepo: Repository<DocumentNumberCounter>,
private dataSource: DataSource,
) {}
async incrementCounter(counterKey: CounterKey): Promise<number> {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return await this.dataSource.transaction(async (manager) => {
// ใช้ Optimistic Locking
const counter = await manager.findOne(DocumentNumberCounter, {
where: this.buildWhereClause(counterKey),
});
if (!counter) {
// สร้าง counter ใหม่
const newCounter = manager.create(DocumentNumberCounter, {
...counterKey,
lastNumber: 1,
version: 0,
});
await manager.save(newCounter);
return 1;
}
counter.lastNumber += 1;
await manager.save(counter); // Auto-check version
return counter.lastNumber;
});
} catch (error) {
if (error instanceof OptimisticLockVersionMismatchError) {
this.logger.warn(
`Version conflict, retry ${attempt + 1}/${MAX_RETRIES}`,
);
if (attempt === MAX_RETRIES - 1) {
throw new ConflictException('เลขที่เอกสารถูกเปลี่ยน กรุณาลองใหม่');
}
continue;
}
throw error;
}
}
}
private buildWhereClause(key: CounterKey) {
return {
projectId: key.projectId,
originatorOrganizationId: key.originatorOrgId,
recipientOrganizationId: key.recipientOrgId,
correspondenceTypeId: key.correspondenceTypeId,
subTypeId: key.subTypeId,
rfaTypeId: key.rfaTypeId,
disciplineId: key.disciplineId,
currentYear: key.year,
};
}
}
```
### 2.5. Main Service with Retry Logic
```typescript
// File: src/modules/document-numbering/services/document-numbering.service.ts
import { Injectable, ServiceUnavailableException, Logger } from '@nestjs/common';
import { DocumentNumberingLockService } from './document-numbering-lock.service';
import { CounterService } from './counter.service';
import { AuditService } from './audit.service';
import { RedisConnectionError } from 'ioredis';
@Injectable()
export class DocumentNumberingService {
private readonly logger = new Logger(DocumentNumberingService.name);
constructor(
private lockService: DocumentNumberingLockService,
private counterService: CounterService,
private auditService: AuditService,
) {}
async generateDocumentNumber(dto: GenerateNumberDto): Promise<string> {
const startTime = Date.now();
let lockWaitMs = 0;
let retryCount = 0;
let fallbackUsed = 'NONE';
try {
// พยายามใช้ Redis lock ก่อน
return await this.generateWithRedisLock(dto);
} catch (error) {
if (error instanceof RedisConnectionError) {
// Fallback: ใช้ database lock
this.logger.warn('Redis unavailable, falling back to DB lock');
fallbackUsed = 'DB_LOCK';
return await this.generateWithDbLock(dto);
}
throw error;
} finally {
// บันทึก audit log
await this.auditService.logGeneration({
documentId: dto.documentId,
counterKey: dto.counterKey,
lockWaitMs,
totalDurationMs: Date.now() - startTime,
fallbackUsed,
retryCount,
});
}
}
private async generateWithRedisLock(dto: GenerateNumberDto): Promise<string> {
const lock = await this.lockService.acquireLock(dto.counterKey);
try {
const nextNumber = await this.counterService.incrementCounter(dto.counterKey);
return this.formatNumber(dto.template, nextNumber, dto.counterKey);
} finally {
await this.lockService.releaseLock(lock);
}
}
private async generateWithDbLock(dto: GenerateNumberDto): Promise<string> {
// ใช้ pessimistic lock
// Implementation details...
}
private formatNumber(template: string, seq: number, key: CounterKey): string {
// Template formatting logic
// Example: `คคง.-สคฉ.3-0001-2568`
return template
.replace('{SEQ:4}', seq.toString().padStart(4, '0'))
.replace('{YEAR:B.E.}', (key.year + 543).toString());
// ... more replacements
}
}
```
## 3. Template Validation
```typescript
// File: src/modules/document-numbering/validators/template.validator.ts
import { Injectable } from '@nestjs/common';
interface ValidationResult {
valid: boolean;
errors: string[];
}
@Injectable()
export class TemplateValidator {
private readonly ALLOWED_TOKENS = [
'PROJECT', 'ORIGINATOR', 'RECIPIENT', 'CORR_TYPE',
'SUB_TYPE', 'RFA_TYPE', 'DISCIPLINE', 'SEQ', 'YEAR', 'REV',
];
validate(template: string, correspondenceType: string): ValidationResult {
const tokens = this.extractTokens(template);
const errors: string[] = [];
// ตรวจสอบ Token ที่ไม่รู้จัก
for (const token of tokens) {
if (!this.ALLOWED_TOKENS.includes(token.name)) {
errors.push(`Unknown token: {${token.name}}`);
}
}
// กฎพิเศษสำหรับแต่ละประเภท
if (correspondenceType === 'RFA') {
if (!tokens.some((t) => t.name === 'PROJECT')) {
errors.push('RFA template ต้องมี {PROJECT}');
}
if (!tokens.some((t) => t.name === 'DISCIPLINE')) {
errors.push('RFA template ต้องมี {DISCIPLINE}');
}
}
if (correspondenceType === 'TRANSMITTAL') {
if (!tokens.some((t) => t.name === 'SUB_TYPE')) {
errors.push('TRANSMITTAL template ต้องมี {SUB_TYPE}');
}
}
// ทุก template ต้องมี {SEQ}
if (!tokens.some((t) => t.name.startsWith('SEQ'))) {
errors.push('Template ต้องมี {SEQ:n}');
}
return { valid: errors.length === 0, errors };
}
private extractTokens(template: string) {
const regex = /\{([^}]+)\}/g;
const tokens: Array<{ name: string; full: string }> = [];
let match;
while ((match = regex.exec(template)) !== null) {
const tokenName = match[1].split(':')[0]; // SEQ:4 → SEQ
tokens.push({ name: tokenName, full: match[1] });
}
return tokens;
}
}
```
## 4. BullMQ Job for Counter Reset
```typescript
// File: src/modules/document-numbering/jobs/counter-reset.job.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
@Processor('document-numbering')
@Injectable()
export class CounterResetJob extends WorkerHost {
private readonly logger = new Logger(CounterResetJob.name);
@Cron('0 0 1 1 *') // 1 Jan every year at 00:00
async handleYearlyReset() {
const newYear = new Date().getFullYear();
// ไม่ต้อง reset counter เพราะ counter แยกตาม current_year อยู่แล้ว
// แค่เตรียม counter สำหรับปีใหม่
this.logger.log(`Year changed to ${newYear}, counters are ready`);
// สามารถทำ cleanup counter ปีเก่าได้ (optional)
// await this.cleanupOldCounters(newYear - 5); // เก็บ 5 ปี
}
async process() {
// BullMQ job processing
}
}
```
## 5. API Controller
### 5.1. Main Controller (`/document-numbering`)
```typescript
// File: src/modules/document-numbering/document-numbering.controller.ts
import {
Controller, Get, Post, Patch,
Body, Param, Query, UseGuards, ParseIntPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { DocumentNumberingService } from './document-numbering.service';
import { PreviewNumberDto } from './dto/preview-number.dto';
@Controller('document-numbering')
@UseGuards(JwtAuthGuard, RbacGuard)
export class DocumentNumberingController {
constructor(private readonly numberingService: DocumentNumberingService) {}
// --- Logs ---
@Get('logs/audit')
@RequirePermission('system.view_logs')
getAuditLogs(@Query('limit') limit?: number) {
return this.numberingService.getAuditLogs(limit ? Number(limit) : 100);
}
@Get('logs/errors')
@RequirePermission('system.view_logs')
getErrorLogs(@Query('limit') limit?: number) {
return this.numberingService.getErrorLogs(limit ? Number(limit) : 100);
}
// --- Sequences / Counters ---
@Get('sequences')
@RequirePermission('correspondence.read')
getSequences(@Query('projectId') projectId?: number) {
return this.numberingService.getSequences(projectId ? Number(projectId) : undefined);
}
@Patch('counters/:id')
@RequirePermission('system.manage_settings')
async updateCounter(
@Param('id', ParseIntPipe) id: number,
@Body('sequence') sequence: number
) {
return this.numberingService.setCounterValue(id, sequence);
}
// --- Preview ---
@Post('preview')
@RequirePermission('correspondence.read')
async previewNumber(@Body() dto: PreviewNumberDto) {
return this.numberingService.previewNumber(dto);
}
}
```
### 5.2. Admin Controller (`/admin/document-numbering`)
```typescript
// File: src/modules/document-numbering/document-numbering-admin.controller.ts
import {
Controller, Get, Post, Delete, Body, Param, Query,
UseGuards, ParseIntPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { DocumentNumberingService } from './document-numbering.service';
@Controller('admin/document-numbering')
@UseGuards(JwtAuthGuard, RbacGuard)
export class DocumentNumberingAdminController {
constructor(private readonly service: DocumentNumberingService) {}
// --- Template Management ---
@Get('templates')
@RequirePermission('system.manage_settings')
async getTemplates(@Query('projectId') projectId?: number) {
if (projectId) {
return this.service.getTemplatesByProject(projectId);
}
return this.service.getTemplates();
}
@Post('templates')
@RequirePermission('system.manage_settings')
async saveTemplate(@Body() dto: any) {
return this.service.saveTemplate(dto);
}
@Delete('templates/:id')
@RequirePermission('system.manage_settings')
async deleteTemplate(@Param('id', ParseIntPipe) id: number) {
await this.service.deleteTemplate(id);
return { success: true };
}
// --- Metrics ---
@Get('metrics')
@RequirePermission('system.view_logs')
async getMetrics() {
const audit = await this.service.getAuditLogs(50);
const errors = await this.service.getErrorLogs(50);
return { audit, errors };
}
// --- Admin Operations ---
@Post('manual-override')
@RequirePermission('system.manage_settings')
async manualOverride(@Body() dto: {
projectId: number;
correspondenceTypeId: number | null;
year: number;
newValue: number;
}) {
return this.service.manualOverride(dto);
}
@Post('void-and-replace')
@RequirePermission('system.manage_settings')
async voidAndReplace(@Body() dto: {
documentId: number;
reason: string;
}) {
return this.service.voidAndReplace(dto);
}
@Post('cancel')
@RequirePermission('system.manage_settings')
async cancelNumber(@Body() dto: {
documentNumber: string;
reason: string;
}) {
return this.service.cancelNumber(dto);
}
@Post('bulk-import')
@RequirePermission('system.manage_settings')
async bulkImport(@Body() items: any[]) {
return this.service.bulkImport(items);
}
}
```
### 5.3. API Endpoints Summary
| Endpoint | Method | Permission | Description |
| -------------------------------------------- | ------ | ------------------------ | --------------------------------- |
| `/document-numbering/logs/audit` | GET | `system.view_logs` | Get audit logs |
| `/document-numbering/logs/errors` | GET | `system.view_logs` | Get error logs |
| `/document-numbering/sequences` | GET | `correspondence.read` | Get counter sequences |
| `/document-numbering/counters/:id` | PATCH | `system.manage_settings` | Update counter value |
| `/document-numbering/preview` | POST | `correspondence.read` | Preview number without generating |
| `/admin/document-numbering/templates` | GET | `system.manage_settings` | Get all templates |
| `/admin/document-numbering/templates` | POST | `system.manage_settings` | Create/update template |
| `/admin/document-numbering/templates/:id` | DELETE | `system.manage_settings` | Delete template |
| `/admin/document-numbering/metrics` | GET | `system.view_logs` | Get metrics (audit + errors) |
| `/admin/document-numbering/manual-override` | POST | `system.manage_settings` | Override counter value |
| `/admin/document-numbering/void-and-replace` | POST | `system.manage_settings` | Void and replace number |
| `/admin/document-numbering/cancel` | POST | `system.manage_settings` | Cancel a number |
| `/admin/document-numbering/bulk-import` | POST | `system.manage_settings` | Bulk import counters |
## 6. Module Configuration
```typescript
// File: src/modules/document-numbering/document-numbering.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq';
import { ThrottlerModule } from '@nestjs/throttler';
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
import { DocumentNumberError } from './entities/document-number-error.entity';
import { DocumentNumberingService } from './services/document-numbering.service';
import { DocumentNumberingLockService } from './services/document-numbering-lock.service';
import { CounterService } from './services/counter.service';
import { AuditService } from './services/audit.service';
import { TemplateValidator } from './validators/template.validator';
import { CounterResetJob } from './jobs/counter-reset.job';
import { DocumentNumberingController } from './controllers/document-numbering.controller';
@Module({
imports: [
TypeOrmModule.forFeature([
DocumentNumberCounter,
DocumentNumberAudit,
DocumentNumberError,
]),
BullModule.registerQueue({
name: 'document-numbering',
}),
ThrottlerModule.forRoot({
ttl: 60,
limit: 10,
}),
],
controllers: [DocumentNumberingController],
providers: [
DocumentNumberingService,
DocumentNumberingLockService,
CounterService,
AuditService,
TemplateValidator,
CounterResetJob,
],
exports: [DocumentNumberingService],
})
export class DocumentNumberingModule {}
```
## 7. Environment Configuration
```typescript
// .env.example
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=lcbp3
DB_PASSWORD=
DB_DATABASE=lcbp3_db
DB_POOL_SIZE=20
# Prometheus
PROMETHEUS_PORT=9090
```
## References
- [Requirements](file:///e:/np-dms/lcbp3/specs/01-requirements/03.11-document-numbering.md)
- [Operations Guide](file:///e:/np-dms/lcbp3/specs/04-operations/document-numbering-operations.md)
- [Backend Guidelines](file:///e:/np-dms/lcbp3/specs/03-implementation/backend-guidelines.md)

Some files were not shown because too many files have changed in this diff Show More