Files
lcbp3/specs/06-tasks/TASK-BE-012-master-data-management.md
admin 047e1b88ce
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
Main: revise specs to 1.5.0 (completed)
2025-12-01 01:28:32 +07:00

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

  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

// 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


📦 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)