1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/org_chart_service.go
chan 349cdf5fcd feat(org): 조직도 렌더링 및 동기화 로직 대폭 개선, 역할(Role) 강제화, 탭 정리
- 조직도 렌더링 시 너비 동적 계산 및 스크롤 문제 해결
- 하위 조직(Leaf)을 부모 박스 내부에 임베딩하여 2열로 깔끔하게 표시되도록 조직도 UI 전면 개편
- 사용자 생성/수정 및 CSV 업로드 시 직급(Position)과 직무(JobTitle)가 정상적으로 Kratos 및 로컬 DB에 동기화되도록 백엔드 API 수정
- CSV 조직도 업로드 시 계층 구분을 '/' 대신 ' > '로 변경하여 이름에 '/'가 포함된 부서(예: 평면/셀)가 분리되지 않도록 보호
- 잘못 입력된 과거 직책 데이터(팀장, 그룹장 등)를 'user' 권한으로 일괄 초기화하고, 이후 'role' 필드에 시스템 권한(user, tenant_admin, super_admin) 외의 값이 들어오지 않도록 백엔드 정규화 로직 강화
- 사용자 목록 페이지의 페이지네이션 제한을 50명에서 1000명으로 상향 조정
- 테넌트 목록 페이지에 이름/슬러그 기반 검색 기능 추가
- 관리자 UI 전반에서 불필요한 배지(Admin only, System 등) 제거 및 테넌트 상세 페이지의 미사용 '외부 연동' 탭 삭제
2026-04-10 13:48:12 +09:00

508 lines
17 KiB
Go

package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/csv"
"fmt"
"io"
"log/slog"
"regexp"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
)
var whitespaceRegex = regexp.MustCompile(`\s+`)
var nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
type ProgressData struct {
Current int `json:"current"`
Total int `json:"total"`
}
var ImportProgressCache sync.Map
type ImportResult struct {
TotalRows int `json:"totalRows"`
Processed int `json:"processed"`
UserCreated int `json:"userCreated"`
UserUpdated int `json:"userUpdated"`
TenantCreated int `json:"tenantCreated"`
Errors []string `json:"errors"`
}
type OrgChartService interface {
ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error)
}
type orgChartService struct {
tenantRepo repository.TenantRepository
userGroupRepo repository.UserGroupRepository
userRepo repository.UserRepository
ketoOutboxRepo repository.KetoOutboxRepository
kratos KratosAdminService
}
func NewOrgChartService(
tenantRepo repository.TenantRepository,
userGroupRepo repository.UserGroupRepository,
userRepo repository.UserRepository,
ketoOutbox repository.KetoOutboxRepository,
kratos KratosAdminService,
) OrgChartService {
return &orgChartService{
tenantRepo: tenantRepo,
userGroupRepo: userGroupRepo,
userRepo: userRepo,
ketoOutboxRepo: ketoOutbox,
kratos: kratos,
}
}
func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error) {
result := &ImportResult{Errors: make([]string, 0)}
var allSheetsRecords [][][]string
var err error
if strings.HasSuffix(strings.ToLower(filename), ".xlsx") {
allSheetsRecords, err = s.readAllXLSXSheets(r)
} else {
csvRecords, csvErr := s.readCSV(r)
if csvErr == nil {
allSheetsRecords = [][][]string{csvRecords}
}
err = csvErr
}
if err != nil {
return nil, err
}
fieldMapping := map[string][]string{
"email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"},
"name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"},
"position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"},
"jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"},
"phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"},
"company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"},
"is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"},
}
var dataRows [][]string
actualMap := make(map[string]int)
found := false
var headerMap map[string]int
for sheetIdx, records := range allSheetsRecords {
for i, row := range records {
if len(row) < 2 { continue }
tempMap := make(map[string]int)
for j, cell := range row {
clean := s.cleanHeader(cell)
if clean != "" { tempMap[clean] = j }
}
emailIdx := s.findBestMatch(tempMap, fieldMapping["email"])
nameIdx := s.findBestMatch(tempMap, fieldMapping["name"])
if nameIdx != -1 && emailIdx == -1 {
for j, cell := range row {
c := s.cleanHeader(cell)
if strings.Contains(c, "mail") || strings.Contains(c, "계정") || strings.Contains(c, "id") || strings.Contains(c, "사번") {
emailIdx = j; break
}
}
}
if emailIdx != -1 && nameIdx != -1 {
dataRows = records[i+1:]
headerMap = tempMap
for key, aliases := range fieldMapping {
actualMap[key] = s.findBestMatch(tempMap, aliases)
}
if actualMap["email"] == -1 { actualMap["email"] = emailIdx }
found = true
slog.Info("Found header row", "sheet", sheetIdx, "row", i)
break
}
}
if found { break }
}
if !found {
return nil, fmt.Errorf("required columns (email/name) not found. please check your headers")
}
// [MH-OrgChart-Standalone Architecture]
// Hierarchy is explicitly ordered: 부서(part) -> 그룹(gr) -> 디비전(div) -> 팀(team) -> 셀(cell)
hierarchyLevels := [][]string{
{"department", "organization", "부서", "조직", "부서명", "조직명", "소속부서", "part", "파트", "본부", "실", "국"},
{"gr", "grp", "group", "그룹"},
{"div", "division", "디비젼", "디비전"},
{"team", "팀", "teal", "팀명"},
{"cell", "셀"},
}
hierarchyIdx := make([]int, 0)
for _, aliases := range hierarchyLevels {
idx := s.findBestMatch(headerMap, aliases)
hierarchyIdx = append(hierarchyIdx, idx) // Keep order, -1 means not found
}
pathCache := make(map[string]string)
result.TotalRows = len(dataRows)
if progressID != "" {
ImportProgressCache.Store(progressID, ProgressData{Current: 0, Total: result.TotalRows})
defer ImportProgressCache.Delete(progressID)
}
if tenantID == "root" || tenantID == "" {
t, _ := s.tenantRepo.FindBySlug(ctx, "root-group")
if t == nil {
tenantID = uuid.NewString()
_ = s.tenantRepo.Create(ctx, &domain.Tenant{ID: tenantID, Name: "Root Group", Slug: "root-group", Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive})
result.TenantCreated++
} else {
tenantID = t.ID
}
}
for rowIdx, record := range dataRows {
if len(record) == 0 { continue }
email := s.getVal(record, actualMap["email"])
name := s.getVal(record, actualMap["name"])
if email == "" || name == "" { continue }
position := s.getVal(record, actualMap["position"])
jobTitle := s.getVal(record, actualMap["jobtitle"])
phone := s.normalizePhone(s.getVal(record, actualMap["phone"]))
companyName := s.getVal(record, actualMap["company"])
if companyName == "" { companyName = "Main" }
companySlug := s.generateCompanySlug(companyName)
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err))
continue
}
// Build orgPath following the strict order: 부서 -> 그룹 -> 디비전 -> 팀 -> 셀
var orgParts []string
for _, idx := range hierarchyIdx {
val := s.getVal(record, idx)
if val != "" && val != "-" {
orgParts = append(orgParts, val)
}
}
orgPath := strings.Join(orgParts, " > ")
leafID := companyTenantID
if len(orgParts) > 0 {
// [Matrix Fix] Build hierarchy under the Root Tenant (tenantID), NOT the individual company.
// This allows departments like '총괄기획실' to be shared across multiple companies without duplication.
leafID, err = s.ensureOrgPath(ctx, tenantID, orgParts, pathCache, result)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Hierarchy fail: %v", rowIdx+2, err))
continue
}
}
isOwner := false
grade := "member"
if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) {
grade = strings.TrimSpace(record[idx])
isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" ||
strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") ||
strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장")
}
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
if (err != nil || kratosID == "") && phone != "" {
kratosID, _ = s.kratos.FindIdentityIDByIdentifier(ctx, phone)
}
if kratosID == "" {
brokerUser := &domain.BrokerUser{
Email: email, Name: name, PhoneNumber: phone,
Attributes: map[string]interface{}{
"affiliationType": "AFFILIATE", "companyCode": companySlug,
"department": orgPath, "grade": grade, "position": position,
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
},
}
kratosID, err = s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#")
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: User creation failed: %v", rowIdx+2, err))
continue
}
result.UserCreated++
} else {
traits := map[string]interface{}{
"name": name, "companyCode": companySlug, "department": orgPath,
"grade": grade, "position": position, "affiliationType": "AFFILIATE",
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
}
if phone != "" {
traits["phone_number"] = phone
}
_, _ = s.kratos.UpdateIdentity(ctx, kratosID, traits, "active")
result.UserUpdated++
}
err = s.userRepo.Update(ctx, &domain.User{
ID: kratosID, Email: email, Name: name, Phone: phone, Position: position,
JobTitle: jobTitle, Department: orgPath,
TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity
CompanyCode: companySlug, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), Role: domain.RoleUser,
})
if err != nil {
slog.Error("Row update failed", "row", rowIdx+2, "email", email, "error", err)
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: DB Update fail: %v", rowIdx+2, err))
}
if s.ketoOutboxRepo != nil {
// 1. [Redundant Assignment] Always assign to the Legal Company Tenant
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: companyTenantID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
// 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists)
if leafID != companyTenantID {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
// 3. Assign ownership if leader
if isOwner {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "owners",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
}
result.Processed++
if progressID != "" && (result.Processed%5 == 0 || result.Processed == result.TotalRows) {
ImportProgressCache.Store(progressID, ProgressData{Current: result.Processed, Total: result.TotalRows})
}
}
return result, nil
}
func (s *orgChartService) cleanHeader(val string) string {
clean := strings.ToLower(whitespaceRegex.ReplaceAllString(val, ""))
clean = nonAlphaNumRegex.ReplaceAllString(clean, "")
return strings.TrimPrefix(clean, "\ufeff")
}
func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int {
for _, alias := range aliases {
ca := s.cleanHeader(alias)
if idx, ok := tempMap[ca]; ok { return idx }
}
for cleaned, idx := range tempMap {
for _, alias := range aliases {
ca := s.cleanHeader(alias)
if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) { return idx }
}
}
return -1
}
func (s *orgChartService) getVal(record []string, idx int) string {
if idx == -1 || idx >= len(record) { return "" }
return strings.TrimSpace(record[idx])
}
func (s *orgChartService) normalizePhone(phone string) string {
normalized := strings.ReplaceAll(phone, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
re := regexp.MustCompile(`[^0-9+]`)
normalized = re.ReplaceAllString(normalized, "")
if len(normalized) < 8 {
return ""
}
if strings.HasPrefix(normalized, "010") {
return "+82" + normalized[1:]
}
if strings.HasPrefix(normalized, "82") {
return "+" + normalized
}
if !strings.HasPrefix(normalized, "+") && len(normalized) >= 9 {
if strings.HasPrefix(normalized, "0") {
return "+82" + normalized[1:]
}
return "+82" + normalized
}
return normalized
}
func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
data, err := io.ReadAll(r)
if err != nil { return nil, err }
reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))))
reader.LazyQuotes = true
reader.FieldsPerRecord = -1
return reader.ReadAll()
}
func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
f, err := excelize.OpenReader(r)
if err != nil { return nil, err }
defer f.Close()
var allRecords [][][]string
for _, sheet := range f.GetSheetList() {
if rows, err := f.GetRows(sheet); err == nil { allRecords = append(allRecords, rows) }
}
return allRecords, nil
}
func (s *orgChartService) generateCompanySlug(name string) string {
n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, ""))
slugs := map[string]string{
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
"ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla",
}
for k, v := range slugs {
if strings.Contains(n, k) || strings.Contains(n, v) { return v }
}
return utils.GenerateSlug(name)
}
func isAlphaNumeric(s string) bool {
for _, r := range s {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return false }
}
return true
}
func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, slug, email string, cache map[string]string, res *ImportResult) (string, error) {
if rootID == "root" || rootID == "" {
// Auto-provision a root group if none is provided
rootSlug := "root-group"
t, _ := s.tenantRepo.FindBySlug(ctx, rootSlug)
if t == nil {
t = &domain.Tenant{ID: uuid.NewString(), Name: "Root Group", Slug: rootSlug, Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive}
_ = s.tenantRepo.Create(ctx, t)
res.TenantCreated++
}
rootID = t.ID
}
cacheKey := "company:" + slug
if id, ok := cache[cacheKey]; ok { return id, nil }
tenant, _ := s.tenantRepo.FindBySlug(ctx, slug)
if tenant == nil {
tenant, _ = s.tenantRepo.FindByName(ctx, name)
}
if tenant == nil {
tenant = &domain.Tenant{ID: uuid.NewString(), Name: name, Slug: slug, Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, ParentID: &rootID}
if err := s.tenantRepo.Create(ctx, tenant); err != nil { return "", err }
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate})
}
res.TenantCreated++
}
domainPart := ""
if parts := strings.Split(email, "@"); len(parts) == 2 { domainPart = parts[1] }
if domainPart != "" { _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) }
cache[cacheKey] = tenant.ID
return tenant.ID, nil
}
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, parts []string, cache map[string]string, res *ImportResult) (string, error) {
currentParentID := rootTenantID
currentPath := ""
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "-" { continue }
if currentPath == "" { currentPath = part } else { currentPath += "/" + part }
cacheKey := rootTenantID + ":" + currentPath
if id, ok := cache[cacheKey]; ok {
currentParentID = id; continue
}
var existingID string
if groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID); err == nil {
for _, g := range groups {
isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID)
isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID)
if g.Name == part && (isTopMatch || isSubMatch) {
existingID = g.ID; break
}
}
}
if existingID == "" {
existingID = uuid.NewString()
groupSlug := fmt.Sprintf("ug-%s", existingID[:13])
if err := s.tenantRepo.Create(ctx, &domain.Tenant{
ID: existingID,
Type: domain.TenantTypeUserGroup,
ParentID: &currentParentID,
Name: part,
Slug: groupSlug,
Status: domain.TenantStatusActive,
}); err != nil {
return "", err
}
var ugParentID *string
if currentParentID != rootTenantID {
pid := currentParentID
ugParentID = &pid
}
if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{
ID: existingID,
TenantID: rootTenantID,
ParentID: ugParentID,
Name: part,
UnitType: s.guessUnitType(i, len(parts)),
}); err != nil {
return "", err
}
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: existingID, Relation: "parents", Subject: "Tenant:" + currentParentID, Action: domain.KetoOutboxActionCreate})
}
res.TenantCreated++
}
cache[cacheKey] = existingID
currentParentID = existingID
}
return currentParentID, nil
}
func (s *orgChartService) guessUnitType(index, total int) string {
if total == 1 { return "Team" }
if index == 0 { return "Division" }
return "Team"
}