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+`) 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: ¤tParentID, 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" }