Main: revise specs to 1.5.0 (completed)
This commit is contained in:
641
specs/06-tasks/TASK-BE-012-master-data-management.md
Normal file
641
specs/06-tasks/TASK-BE-012-master-data-management.md
Normal file
@@ -0,0 +1,641 @@
|
||||
# Task: Master Data Management Module
|
||||
|
||||
**Status:** Not Started
|
||||
**Priority:** P1 (High - Required for System Setup)
|
||||
**Estimated Effort:** 6-8 days
|
||||
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)
|
||||
**Owner:** Backend Team
|
||||
|
||||
---
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
สร้าง Master Data Management Module สำหรับจัดการข้อมูลหลักของระบบ ที่ใช้สำหรับ Configuration และ Dropdown Lists
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Objectives
|
||||
|
||||
- ✅ Organization Management (CRUD)
|
||||
- ✅ Project & Contract Management
|
||||
- ✅ Type/Category Management
|
||||
- ✅ Discipline Management
|
||||
- ✅ Code Management (RFA Approve Codes, etc.)
|
||||
- ✅ User Preferences
|
||||
|
||||
---
|
||||
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Organization Management:**
|
||||
|
||||
- ✅ Create/Update/Delete organizations
|
||||
- ✅ Active/Inactive toggle
|
||||
- ✅ Organization hierarchy (if needed)
|
||||
- ✅ Unique organization codes
|
||||
|
||||
2. **Project & Contract Management:**
|
||||
|
||||
- ✅ Create/Update/Delete projects
|
||||
- ✅ Link projects to organizations
|
||||
- ✅ Create/Update/Delete contracts
|
||||
- ✅ Link contracts to projects
|
||||
|
||||
3. **Type Management:**
|
||||
|
||||
- ✅ Correspondence Types CRUD
|
||||
- ✅ RFA Types CRUD
|
||||
- ✅ Drawing Categories CRUD
|
||||
- ✅ Correspondence Sub Types CRUD
|
||||
|
||||
4. **Discipline Management:**
|
||||
|
||||
- ✅ Create/Update disciplines
|
||||
- ✅ Discipline codes (GEN, STR, ARC, etc.)
|
||||
- ✅ Active/Inactive status
|
||||
|
||||
5. **Code Management:**
|
||||
- ✅ RFA Approve Codes CRUD
|
||||
- ✅ Other lookup codes
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Implementation Steps
|
||||
|
||||
### 1. Organization Module
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/master-data/organization/organization.service.ts
|
||||
@Injectable()
|
||||
export class OrganizationService {
|
||||
constructor(
|
||||
@InjectRepository(Organization)
|
||||
private orgRepo: Repository<Organization>
|
||||
) {}
|
||||
|
||||
async create(dto: CreateOrganizationDto): Promise<Organization> {
|
||||
// Check unique code
|
||||
const existing = await this.orgRepo.findOne({
|
||||
where: { organization_code: dto.organization_code },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Organization code already exists');
|
||||
}
|
||||
|
||||
const organization = this.orgRepo.create({
|
||||
organization_code: dto.organization_code,
|
||||
organization_name: dto.organization_name,
|
||||
organization_name_en: dto.organization_name_en,
|
||||
address: dto.address,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
return this.orgRepo.save(organization);
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateOrganizationDto): Promise<Organization> {
|
||||
const organization = await this.findOne(id);
|
||||
|
||||
// Check unique code if changed
|
||||
if (
|
||||
dto.organization_code &&
|
||||
dto.organization_code !== organization.organization_code
|
||||
) {
|
||||
const existing = await this.orgRepo.findOne({
|
||||
where: { organization_code: dto.organization_code },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Organization code already exists');
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(organization, dto);
|
||||
return this.orgRepo.save(organization);
|
||||
}
|
||||
|
||||
async findAll(includeInactive: boolean = false): Promise<Organization[]> {
|
||||
const where: any = {};
|
||||
if (!includeInactive) {
|
||||
where.is_active = true;
|
||||
}
|
||||
|
||||
return this.orgRepo.find({
|
||||
where,
|
||||
order: { organization_code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Organization> {
|
||||
const organization = await this.orgRepo.findOne({ where: { id } });
|
||||
|
||||
if (!organization) {
|
||||
throw new NotFoundException(`Organization #${id} not found`);
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
async toggleActive(id: number): Promise<Organization> {
|
||||
const organization = await this.findOne(id);
|
||||
organization.is_active = !organization.is_active;
|
||||
return this.orgRepo.save(organization);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
// Check if organization has any related data
|
||||
const hasProjects = await this.hasRelatedProjects(id);
|
||||
if (hasProjects) {
|
||||
throw new BadRequestException(
|
||||
'Cannot delete organization with related projects'
|
||||
);
|
||||
}
|
||||
|
||||
await this.orgRepo.softDelete(id);
|
||||
}
|
||||
|
||||
private async hasRelatedProjects(organizationId: number): Promise<boolean> {
|
||||
const count = await this.orgRepo
|
||||
.createQueryBuilder('org')
|
||||
.leftJoin(
|
||||
'projects',
|
||||
'p',
|
||||
'p.client_organization_id = org.id OR p.consultant_organization_id = org.id'
|
||||
)
|
||||
.where('org.id = :id', { id: organizationId })
|
||||
.getCount();
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Project & Contract Module
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/master-data/project/project.service.ts
|
||||
@Injectable()
|
||||
export class ProjectService {
|
||||
constructor(
|
||||
@InjectRepository(Project)
|
||||
private projectRepo: Repository<Project>,
|
||||
@InjectRepository(Contract)
|
||||
private contractRepo: Repository<Contract>
|
||||
) {}
|
||||
|
||||
async createProject(dto: CreateProjectDto): Promise<Project> {
|
||||
const project = this.projectRepo.create({
|
||||
project_code: dto.project_code,
|
||||
project_name: dto.project_name,
|
||||
project_name_en: dto.project_name_en,
|
||||
client_organization_id: dto.client_organization_id,
|
||||
consultant_organization_id: dto.consultant_organization_id,
|
||||
start_date: dto.start_date,
|
||||
end_date: dto.end_date,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
return this.projectRepo.save(project);
|
||||
}
|
||||
|
||||
async createContract(dto: CreateContractDto): Promise<Contract> {
|
||||
// Verify project exists
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { id: dto.project_id },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project #${dto.project_id} not found`);
|
||||
}
|
||||
|
||||
const contract = this.contractRepo.create({
|
||||
contract_number: dto.contract_number,
|
||||
contract_name: dto.contract_name,
|
||||
project_id: dto.project_id,
|
||||
contractor_organization_id: dto.contractor_organization_id,
|
||||
start_date: dto.start_date,
|
||||
end_date: dto.end_date,
|
||||
contract_value: dto.contract_value,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
return this.contractRepo.save(contract);
|
||||
}
|
||||
|
||||
async findAllProjects(): Promise<Project[]> {
|
||||
return this.projectRepo.find({
|
||||
where: { is_active: true },
|
||||
relations: ['clientOrganization', 'consultantOrganization', 'contracts'],
|
||||
order: { project_code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findProjectContracts(projectId: number): Promise<Contract[]> {
|
||||
return this.contractRepo.find({
|
||||
where: { project_id: projectId, is_active: true },
|
||||
relations: ['contractorOrganization'],
|
||||
order: { contract_number: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Type Management Service
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/master-data/type/type.service.ts
|
||||
@Injectable()
|
||||
export class TypeService {
|
||||
constructor(
|
||||
@InjectRepository(CorrespondenceType)
|
||||
private corrTypeRepo: Repository<CorrespondenceType>,
|
||||
@InjectRepository(RfaType)
|
||||
private rfaTypeRepo: Repository<RfaType>,
|
||||
@InjectRepository(DrawingCategory)
|
||||
private drawingCategoryRepo: Repository<DrawingCategory>,
|
||||
@InjectRepository(CorrespondenceSubType)
|
||||
private corrSubTypeRepo: Repository<CorrespondenceSubType>
|
||||
) {}
|
||||
|
||||
// Correspondence Types
|
||||
async createCorrespondenceType(
|
||||
dto: CreateTypeDto
|
||||
): Promise<CorrespondenceType> {
|
||||
const type = this.corrTypeRepo.create({
|
||||
type_code: dto.type_code,
|
||||
type_name: dto.type_name,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
return this.corrTypeRepo.save(type);
|
||||
}
|
||||
|
||||
async findAllCorrespondenceTypes(): Promise<CorrespondenceType[]> {
|
||||
return this.corrTypeRepo.find({
|
||||
where: { is_active: true },
|
||||
order: { type_code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
// RFA Types
|
||||
async createRfaType(dto: CreateTypeDto): Promise<RfaType> {
|
||||
const type = this.rfaTypeRepo.create({
|
||||
type_code: dto.type_code,
|
||||
type_name: dto.type_name,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
return this.rfaTypeRepo.save(type);
|
||||
}
|
||||
|
||||
async findAllRfaTypes(): Promise<RfaType[]> {
|
||||
return this.rfaTypeRepo.find({
|
||||
where: { is_active: true },
|
||||
order: { type_code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
// Drawing Categories
|
||||
async createDrawingCategory(dto: CreateTypeDto): Promise<DrawingCategory> {
|
||||
const category = this.drawingCategoryRepo.create({
|
||||
category_code: dto.type_code,
|
||||
category_name: dto.type_name,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
return this.drawingCategoryRepo.save(category);
|
||||
}
|
||||
|
||||
async findAllDrawingCategories(): Promise<DrawingCategory[]> {
|
||||
return this.drawingCategoryRepo.find({
|
||||
where: { is_active: true },
|
||||
order: { category_code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
// Correspondence Sub Types
|
||||
async createCorrespondenceSubType(
|
||||
dto: CreateSubTypeDto
|
||||
): Promise<CorrespondenceSubType> {
|
||||
const subType = this.corrSubTypeRepo.create({
|
||||
correspondence_type_id: dto.correspondence_type_id,
|
||||
sub_type_code: dto.sub_type_code,
|
||||
sub_type_name: dto.sub_type_name,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
return this.corrSubTypeRepo.save(subType);
|
||||
}
|
||||
|
||||
async findCorrespondenceSubTypes(
|
||||
typeId: number
|
||||
): Promise<CorrespondenceSubType[]> {
|
||||
return this.corrSubTypeRepo.find({
|
||||
where: { correspondence_type_id: typeId, is_active: true },
|
||||
order: { sub_type_code: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Discipline Management
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/master-data/discipline/discipline.service.ts
|
||||
@Injectable()
|
||||
export class DisciplineService {
|
||||
constructor(
|
||||
@InjectRepository(Discipline)
|
||||
private disciplineRepo: Repository<Discipline>
|
||||
) {}
|
||||
|
||||
async create(dto: CreateDisciplineDto): Promise<Discipline> {
|
||||
const existing = await this.disciplineRepo.findOne({
|
||||
where: { discipline_code: dto.discipline_code },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Discipline code already exists');
|
||||
}
|
||||
|
||||
const discipline = this.disciplineRepo.create({
|
||||
discipline_code: dto.discipline_code,
|
||||
discipline_name: dto.discipline_name,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
return this.disciplineRepo.save(discipline);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Discipline[]> {
|
||||
return this.disciplineRepo.find({
|
||||
where: { is_active: true },
|
||||
order: { discipline_code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateDisciplineDto): Promise<Discipline> {
|
||||
const discipline = await this.disciplineRepo.findOne({ where: { id } });
|
||||
|
||||
if (!discipline) {
|
||||
throw new NotFoundException(`Discipline #${id} not found`);
|
||||
}
|
||||
|
||||
Object.assign(discipline, dto);
|
||||
return this.disciplineRepo.save(discipline);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. RFA Approve Codes
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/master-data/code/code.service.ts
|
||||
@Injectable()
|
||||
export class CodeService {
|
||||
constructor(
|
||||
@InjectRepository(RfaApproveCode)
|
||||
private rfaApproveCodeRepo: Repository<RfaApproveCode>
|
||||
) {}
|
||||
|
||||
async createRfaApproveCode(
|
||||
dto: CreateApproveCodeDto
|
||||
): Promise<RfaApproveCode> {
|
||||
const code = this.rfaApproveCodeRepo.create({
|
||||
code: dto.code,
|
||||
description: dto.description,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
return this.rfaApproveCodeRepo.save(code);
|
||||
}
|
||||
|
||||
async findAllRfaApproveCodes(): Promise<RfaApproveCode[]> {
|
||||
return this.rfaApproveCodeRepo.find({
|
||||
where: { is_active: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Master Data Controller
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/master-data/master-data.controller.ts
|
||||
@Controller('master-data')
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@ApiTags('Master Data')
|
||||
export class MasterDataController {
|
||||
constructor(
|
||||
private organizationService: OrganizationService,
|
||||
private projectService: ProjectService,
|
||||
private typeService: TypeService,
|
||||
private disciplineService: DisciplineService,
|
||||
private codeService: CodeService
|
||||
) {}
|
||||
|
||||
// Organizations
|
||||
@Get('organizations')
|
||||
async getOrganizations() {
|
||||
return this.organizationService.findAll();
|
||||
}
|
||||
|
||||
@Post('organizations')
|
||||
@RequirePermission('master_data.manage')
|
||||
async createOrganization(@Body() dto: CreateOrganizationDto) {
|
||||
return this.organizationService.create(dto);
|
||||
}
|
||||
|
||||
@Put('organizations/:id')
|
||||
@RequirePermission('master_data.manage')
|
||||
async updateOrganization(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateOrganizationDto
|
||||
) {
|
||||
return this.organizationService.update(id, dto);
|
||||
}
|
||||
|
||||
// Projects
|
||||
@Get('projects')
|
||||
async getProjects() {
|
||||
return this.projectService.findAllProjects();
|
||||
}
|
||||
|
||||
@Post('projects')
|
||||
@RequirePermission('master_data.manage')
|
||||
async createProject(@Body() dto: CreateProjectDto) {
|
||||
return this.projectService.createProject(dto);
|
||||
}
|
||||
|
||||
// Contracts
|
||||
@Get('projects/:projectId/contracts')
|
||||
async getProjectContracts(
|
||||
@Param('projectId', ParseIntPipe) projectId: number
|
||||
) {
|
||||
return this.projectService.findProjectContracts(projectId);
|
||||
}
|
||||
|
||||
@Post('contracts')
|
||||
@RequirePermission('master_data.manage')
|
||||
async createContract(@Body() dto: CreateContractDto) {
|
||||
return this.projectService.createContract(dto);
|
||||
}
|
||||
|
||||
// Correspondence Types
|
||||
@Get('correspondence-types')
|
||||
async getCorrespondenceTypes() {
|
||||
return this.typeService.findAllCorrespondenceTypes();
|
||||
}
|
||||
|
||||
@Post('correspondence-types')
|
||||
@RequirePermission('master_data.manage')
|
||||
async createCorrespondenceType(@Body() dto: CreateTypeDto) {
|
||||
return this.typeService.createCorrespondenceType(dto);
|
||||
}
|
||||
|
||||
// RFA Types
|
||||
@Get('rfa-types')
|
||||
async getRfaTypes() {
|
||||
return this.typeService.findAllRfaTypes();
|
||||
}
|
||||
|
||||
// Disciplines
|
||||
@Get('disciplines')
|
||||
async getDisciplines() {
|
||||
return this.disciplineService.findAll();
|
||||
}
|
||||
|
||||
@Post('disciplines')
|
||||
@RequirePermission('master_data.manage')
|
||||
async createDiscipline(@Body() dto: CreateDisciplineDto) {
|
||||
return this.disciplineService.create(dto);
|
||||
}
|
||||
|
||||
// RFA Approve Codes
|
||||
@Get('rfa-approve-codes')
|
||||
async getRfaApproveCodes() {
|
||||
return this.codeService.findAllRfaApproveCodes();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Testing & Verification
|
||||
|
||||
### 1. Unit Tests
|
||||
|
||||
```typescript
|
||||
describe('OrganizationService', () => {
|
||||
it('should create organization with unique code', async () => {
|
||||
const dto = {
|
||||
organization_code: 'TEST',
|
||||
organization_name: 'Test Organization',
|
||||
};
|
||||
|
||||
const result = await service.create(dto);
|
||||
|
||||
expect(result.organization_code).toBe('TEST');
|
||||
expect(result.is_active).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when creating duplicate code', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
organization_code: 'TEAM',
|
||||
organization_name: 'Duplicate',
|
||||
})
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('should prevent deletion of organization with projects', async () => {
|
||||
await expect(service.delete(1)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProjectService', () => {
|
||||
it('should create project with contracts', async () => {
|
||||
const project = await service.createProject({
|
||||
project_code: 'LCBP3',
|
||||
project_name: 'Laem Chabang Phase 3',
|
||||
client_organization_id: 1,
|
||||
consultant_organization_id: 2,
|
||||
});
|
||||
|
||||
expect(project.project_code).toBe('LCBP3');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Integration Tests
|
||||
|
||||
```bash
|
||||
# Get all organizations
|
||||
curl http://localhost:3000/master-data/organizations
|
||||
|
||||
# Create organization
|
||||
curl -X POST http://localhost:3000/master-data/organizations \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"organization_code": "ABC",
|
||||
"organization_name": "ABC Company"
|
||||
}'
|
||||
|
||||
# Get projects
|
||||
curl http://localhost:3000/master-data/projects
|
||||
|
||||
# Get disciplines
|
||||
curl http://localhost:3000/master-data/disciplines
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documents
|
||||
|
||||
- [Data Model - Master Data](../02-architecture/data-model.md#core--master-data)
|
||||
- [Data Dictionary v1.4.5](../../docs/4_Data_Dictionary_V1_4_5.md)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Deliverables
|
||||
|
||||
- [ ] OrganizationService (CRUD)
|
||||
- [ ] ProjectService & ContractService
|
||||
- [ ] TypeService (Correspondence, RFA, Drawing)
|
||||
- [ ] DisciplineService
|
||||
- [ ] CodeService (RFA Approve Codes)
|
||||
- [ ] MasterDataController (unified endpoints)
|
||||
- [ ] DTOs for all entities
|
||||
- [ ] Unit Tests (80% coverage)
|
||||
- [ ] Integration Tests
|
||||
- [ ] API Documentation (Swagger)
|
||||
- [ ] Seed data scripts
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Risks & Mitigation
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
| ----------------------- | ------ | --------------------------------- |
|
||||
| Duplicate codes | Medium | Unique constraints + validation |
|
||||
| Circular dependencies | Low | Proper foreign key design |
|
||||
| Deletion with relations | High | Check relations before delete |
|
||||
| Data integrity | High | Use transactions for related data |
|
||||
|
||||
---
|
||||
|
||||
## 📌 Notes
|
||||
|
||||
- All master data tables have `is_active` flag
|
||||
- Soft delete for organizations and projects
|
||||
- Unique codes enforced at database level
|
||||
- Organization deletion checks for related projects
|
||||
- Seed data required for initial setup
|
||||
- Admin-only access for create/update/delete
|
||||
- Public read access for dropdown lists
|
||||
- Cache frequently accessed master data (Redis)
|
||||
Reference in New Issue
Block a user