17 KiB
17 KiB
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
-
Organization Management:
- ✅ Create/Update/Delete organizations
- ✅ Active/Inactive toggle
- ✅ Organization hierarchy (if needed)
- ✅ Unique organization codes
-
Project & Contract Management:
- ✅ Create/Update/Delete projects
- ✅ Link projects to organizations
- ✅ Create/Update/Delete contracts
- ✅ Link contracts to projects
-
Type Management:
- ✅ Correspondence Types CRUD
- ✅ RFA Types CRUD
- ✅ Drawing Categories CRUD
- ✅ Correspondence Sub Types CRUD
-
Discipline Management:
- ✅ Create/Update disciplines
- ✅ Discipline codes (GEN, STR, ARC, etc.)
- ✅ Active/Inactive status
-
Code Management:
- ✅ RFA Approve Codes CRUD
- ✅ Other lookup codes
🛠️ Implementation Steps
1. Organization Module
// 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
// 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
// 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
// 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
// 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
// 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
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
# 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
📦 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_activeflag - 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)