增加组织架构

This commit is contained in:
2026-08-27 21:25:39 +08:00
parent 74ab6c4c6d
commit d6bba226d0
41 changed files with 6128 additions and 5218 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+479
View File
@@ -0,0 +1,479 @@
package controllers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"server/models"
)
// 本文件包含组织架构模块的 DTO 组装、树结构与层级校验,以及请求参数解析工具。
// ---------------------------------------------------------------------------
// DTO 组装
// ---------------------------------------------------------------------------
// organizationDTOList 批量组装组织 DTO。一次性把父组织名、负责人名、员工数查出来,
// 避免逐行查询导致的 N+1 问题。
func (c *BackendOrganizationController) organizationDTOList(tid uint64, rows []models.BackendOrganization) []organizationDTO {
list := make([]organizationDTO, 0, len(rows))
if len(rows) == 0 {
return list
}
nameByID := c.orgNameMap(tid)
leaderNames := c.employeeNameMap(tid)
counts := c.employeeCountByDepartment(tid)
for _, row := range rows {
list = append(list, c.assembleOrganizationDTO(row, nameByID, leaderNames, counts))
}
return list
}
func (c *BackendOrganizationController) organizationDTO(tid uint64, row models.BackendOrganization) organizationDTO {
return c.assembleOrganizationDTO(row,
c.orgNameMap(tid), c.employeeNameMap(tid), c.employeeCountByDepartment(tid))
}
func (c *BackendOrganizationController) assembleOrganizationDTO(
row models.BackendOrganization,
nameByID map[uint64]string,
leaderNames map[uint64]string,
counts map[string]int64,
) organizationDTO {
leaderID := uint64(0)
if row.LeaderID != nil {
leaderID = *row.LeaderID
}
return organizationDTO{
ID: row.ID,
Tid: row.Tid,
TenantID: row.Tid,
OrgName: row.OrgName,
OrgCode: row.OrgCode,
ParentID: row.ParentID,
ParentName: nameByID[row.ParentID],
LeaderID: leaderID,
LeaderName: leaderNames[leaderID],
IsCompany: row.IsCompany,
Sort: row.Sort,
Status: row.Status,
Remark: derefString(row.Remark),
EmployeeCount: counts[strconv.FormatUint(row.ID, 10)],
CreateTime: formatDateTime(&row.CreateTime),
UpdateTime: formatDateTime(&row.UpdateTime),
}
}
func (c *BackendOrganizationController) employeeDTOList(tid uint64, rows []models.BackendEmployee) []employeeDTO {
list := make([]employeeDTO, 0, len(rows))
if len(rows) == 0 {
return list
}
nameByID := c.orgNameMap(tid)
for _, row := range rows {
list = append(list, c.assembleEmployeeDTO(row, nameByID))
}
return list
}
func (c *BackendOrganizationController) employeeDTO(tid uint64, row models.BackendEmployee) employeeDTO {
return c.assembleEmployeeDTO(row, c.orgNameMap(tid))
}
func (c *BackendOrganizationController) assembleEmployeeDTO(
row models.BackendEmployee,
nameByID map[uint64]string,
) employeeDTO {
tid := 0
if row.Tid != nil {
tid = *row.Tid
}
birthday := ""
if row.Birthday != nil {
birthday = row.Birthday.Format("2006-01-02")
}
affiliateUnit := derefString(row.AffiliateUnit)
department := derefString(row.Department)
return employeeDTO{
ID: row.ID,
Tid: tid,
TenantID: tid,
Account: row.Account,
Name: row.Name,
Gender: row.Gender,
Sex: row.Gender,
Birthday: birthday,
AffiliateUnit: affiliateUnit,
AffiliateUnitName: orgNameByIDString(nameByID, affiliateUnit),
Department: department,
DepartmentName: orgNameByIDString(nameByID, department),
Position: derefString(row.Position),
Education: derefString(row.Education),
Nation: derefString(row.Nation),
Phone: derefString(row.Phone),
Wechat: derefString(row.Wechat),
Email: derefString(row.Email),
HomeAddress: derefString(row.HomeAddress),
AccountStatus: row.AccountStatus,
Status: row.AccountStatus,
CreateTime: formatDateTime(&row.CreateTime),
}
}
func (c *BackendOrganizationController) positionDTO(tid uint64, row models.BackendPosition) positionDTO {
nameByID := c.orgNameMap(tid)
return positionDTO{
ID: row.ID,
Tid: row.Tid,
TenantID: row.Tid,
DepartmentID: row.DepartmentID,
DepartmentName: nameByID[row.DepartmentID],
PositionCode: row.PositionCode,
PositionName: row.PositionName,
PositionType: row.PositionType,
Status: row.Status,
Sort: row.Sort,
Remark: derefString(row.Remark),
CreateTime: formatDateTime(&row.CreateTime),
}
}
// ---------------------------------------------------------------------------
// 组织关系查询
// ---------------------------------------------------------------------------
func (c *BackendOrganizationController) orgNameMap(tid uint64) map[uint64]string {
result := map[uint64]string{}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&rows, "ID", "OrgName"); err != nil {
return result
}
for _, row := range rows {
result[row.ID] = row.OrgName
}
return result
}
func (c *BackendOrganizationController) employeeNameMap(tid uint64) map[uint64]string {
result := map[uint64]string{}
var rows []models.BackendEmployee
if _, err := c.employeeQuery(tid).All(&rows, "ID", "Name"); err != nil {
return result
}
for _, row := range rows {
result[uint64(row.ID)] = row.Name
}
return result
}
// employeeCountByDepartment 统计各部门(department 存的是组织ID字符串)的员工数。
func (c *BackendOrganizationController) employeeCountByDepartment(tid uint64) map[string]int64 {
result := map[string]int64{}
var rows []models.BackendEmployee
if _, err := c.employeeQuery(tid).All(&rows, "Department"); err != nil {
return result
}
for _, row := range rows {
key := strings.TrimSpace(derefString(row.Department))
if key == "" {
continue
}
result[key]++
}
return result
}
func (c *BackendOrganizationController) orgExists(tid, id uint64) bool {
if id == 0 {
return false
}
return c.orgQuery(tid).Filter("id", id).Exist()
}
// parentMap 返回 组织ID -> 上级组织ID 的映射,用于层级与环路判断。
func (c *BackendOrganizationController) parentMap(tid uint64) map[uint64]uint64 {
result := map[uint64]uint64{}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&rows, "ID", "ParentID"); err != nil {
return result
}
for _, row := range rows {
result[row.ID] = row.ParentID
}
return result
}
// orgDepth 计算组织所在层级,顶级为 1。
func (c *BackendOrganizationController) orgDepth(tid, id uint64) (int, error) {
parents := c.parentMap(tid)
depth := 0
current := id
for current > 0 {
depth++
if depth > 64 {
return depth, errors.New("组织层级数据异常(可能存在环路)")
}
next, ok := parents[current]
if !ok {
break
}
current = next
}
return depth, nil
}
// subtreeHeight 计算以 id 为根的子树高度(只有自身时为 1)。
func (c *BackendOrganizationController) subtreeHeight(tid, id uint64) int {
childrenOf := map[uint64][]uint64{}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&rows, "ID", "ParentID"); err != nil {
return 1
}
for _, row := range rows {
childrenOf[row.ParentID] = append(childrenOf[row.ParentID], row.ID)
}
return subtreeHeightFrom(childrenOf, id, 0)
}
func subtreeHeightFrom(childrenOf map[uint64][]uint64, id uint64, depth int) int {
if depth > 64 {
return depth
}
height := 1
for _, child := range childrenOf[id] {
if h := subtreeHeightFrom(childrenOf, child, depth+1) + 1; h > height {
height = h
}
}
return height
}
// collectOrgIDs 返回 rootID 及其所有下级组织的ID。
func (c *BackendOrganizationController) collectOrgIDs(tid, rootID uint64) []uint64 {
childrenOf := map[uint64][]uint64{}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&rows, "ID", "ParentID"); err != nil {
return []uint64{rootID}
}
for _, row := range rows {
childrenOf[row.ParentID] = append(childrenOf[row.ParentID], row.ID)
}
result := []uint64{rootID}
queue := []uint64{rootID}
visited := map[uint64]bool{rootID: true}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
for _, child := range childrenOf[current] {
if visited[child] {
continue
}
visited[child] = true
result = append(result, child)
queue = append(queue, child)
}
}
return result
}
// rootCompanyID 沿上级链向上找到所属的顶层公司ID。
func (c *BackendOrganizationController) rootCompanyID(tid uint64, org models.BackendOrganization) uint64 {
if org.IsCompany == 1 {
return org.ID
}
parents := c.parentMap(tid)
current := org.ParentID
for i := 0; i < 64 && current > 0; i++ {
var row models.BackendOrganization
if err := c.orgQuery(tid).Filter("id", current).One(&row); err != nil {
return 0
}
if row.IsCompany == 1 {
return row.ID
}
next, ok := parents[current]
if !ok {
return 0
}
current = next
}
return 0
}
// validateParentChange 校验把 orgID 挂到 newParentID 下是否合法:
// 不能挂到自己或自己的后代(形成环路),且移动后总层级不超过设置上限。
func (c *BackendOrganizationController) validateParentChange(tid, orgID, newParentID uint64, settings orgSettings) error {
if newParentID == 0 {
return nil
}
if newParentID == orgID {
return errors.New("上级组织不能是自己")
}
if !c.orgExists(tid, newParentID) {
return errors.New("上级组织不存在")
}
for _, id := range c.collectOrgIDs(tid, orgID) {
if id == newParentID {
return errors.New("不能将组织移动到自己的下级组织中")
}
}
parentDepth, err := c.orgDepth(tid, newParentID)
if err != nil {
return err
}
if settings.MaxOrgLevels > 0 && parentDepth+c.subtreeHeight(tid, orgID) > settings.MaxOrgLevels {
return fmt.Errorf("移动后组织层级将超过上限 %d 级", settings.MaxOrgLevels)
}
if settings.MaxOrgChildren > 0 {
count, err := c.orgQuery(tid).Filter("parent_id", newParentID).
Exclude("id", orgID).Exclude("status", 0).Count()
if err == nil && int(count) >= settings.MaxOrgChildren {
return fmt.Errorf("同一上级下最多 %d 个子组织", settings.MaxOrgChildren)
}
}
return nil
}
// validateEmployeeOrg 校验员工的隶属单位与部门必须是当前租户下已存在的组织。
func (c *BackendOrganizationController) validateEmployeeOrg(tid uint64, affiliateUnit, department string) error {
for label, raw := range map[string]string{"隶属单位": affiliateUnit, "部门": department} {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return fmt.Errorf("%s格式不正确", label)
}
if !c.orgExists(tid, id) {
return fmt.Errorf("%s不存在", label)
}
}
return nil
}
// buildOrganizationTree 把扁平的组织 DTO 列表组装成树。
// 上级不在列表中的节点(如上级被禁用)作为根节点返回,避免数据丢失。
func buildOrganizationTree(list []organizationDTO) []map[string]interface{} {
nodeMap := make(map[uint64]map[string]interface{}, len(list))
order := make([]uint64, 0, len(list))
for _, item := range list {
raw, _ := json.Marshal(item)
node := map[string]interface{}{}
_ = json.Unmarshal(raw, &node)
node["children"] = make([]map[string]interface{}, 0)
nodeMap[item.ID] = node
order = append(order, item.ID)
}
tree := make([]map[string]interface{}, 0)
for _, id := range order {
node := nodeMap[id]
parentID := uint64(0)
if v, ok := node["parent_id"].(float64); ok {
parentID = uint64(v)
}
if parent, exists := nodeMap[parentID]; parentID > 0 && exists {
parent["children"] = append(parent["children"].([]map[string]interface{}), node)
continue
}
tree = append(tree, node)
}
return tree
}
// treeDepth 计算树的最大深度。
func treeDepth(childrenOf map[uint64][]uint64, rootID uint64, depth int) int {
if depth > 64 {
return depth
}
maxDepth := depth
for _, child := range childrenOf[rootID] {
if d := treeDepth(childrenOf, child, depth+1); d > maxDepth {
maxDepth = d
}
}
return maxDepth
}
func orgNameByIDString(nameByID map[uint64]string, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return ""
}
return nameByID[id]
}
func uniqueMessage(unique bool, subject string) string {
if unique {
return subject + "可用"
}
return subject + "已存在"
}
func formatDateTime(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func clampInt(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// generateCode 生成形如 ORG20260827193012 的编码,长度不足时补时间戳末尾数字。
func (c *BackendOrganizationController) generateCode(prefix string, length int) string {
prefix = strings.TrimSpace(prefix)
stamp := time.Now().Format("20060102150405")
code := prefix + stamp
if length > 0 && len(code) > length && len(prefix) < length {
keep := length - len(prefix)
code = prefix + stamp[len(stamp)-keep:]
}
return code
}
// hashEmployeePassword 适配 password varchar(64),使用 sha256 hex;空密码返回空串。
func hashEmployeePassword(plain string) string {
plain = strings.TrimSpace(plain)
if plain == "" {
return ""
}
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,289 @@
package controllers
import (
"encoding/json"
"strconv"
"strings"
"time"
)
// 本文件包含组织架构模块的请求参数解析工具。
// 前端存在 JSON、form-urlencoded 与 multipart/form-data 三种提交方式,
// 因此每个取值函数都先读 JSON body,再回退到表单参数。
func (c *BackendOrganizationController) parseJSONBody() map[string]interface{} {
body := map[string]interface{}{}
contentType := strings.ToLower(c.Ctx.Input.Header("Content-Type"))
if !strings.Contains(contentType, "json") {
return body
}
if len(c.Ctx.Input.RequestBody) == 0 {
return body
}
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &body)
return body
}
// ensureFormParsed 在读取表单参数前确保请求体已被解析。
func (c *BackendOrganizationController) ensureFormParsed() {
if c.Ctx.Request.Form == nil && c.Ctx.Request.PostForm == nil && c.Ctx.Request.MultipartForm == nil {
_ = c.Ctx.Request.ParseMultipartForm(32 << 20)
}
}
func (c *BackendOrganizationController) getStringValue(body map[string]interface{}, keys ...string) (string, bool) {
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case string:
return val, true
case float64:
return strconv.FormatFloat(val, 'f', -1, 64), true
case bool:
return strconv.FormatBool(val), true
case nil:
return "", true
}
}
c.ensureFormParsed()
if val := c.GetString(key); val != "" {
return val, true
}
}
return "", false
}
func (c *BackendOrganizationController) getIntValue(body map[string]interface{}, keys ...string) (int, bool) {
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case float64:
return int(val), true
case int:
return val, true
case bool:
return boolInt(val), true
case string:
trimmed := strings.TrimSpace(val)
if trimmed == "" {
return 0, true
}
parsed, err := strconv.Atoi(trimmed)
return parsed, err == nil
}
}
c.ensureFormParsed()
if val := c.GetString(key); val != "" {
parsed, err := strconv.Atoi(strings.TrimSpace(val))
return parsed, err == nil
}
}
return 0, false
}
func (c *BackendOrganizationController) getUintValue(body map[string]interface{}, keys ...string) (uint, bool) {
v, ok := c.getIntValue(body, keys...)
if !ok || v < 0 {
return 0, ok && v >= 0
}
return uint(v), true
}
func (c *BackendOrganizationController) getUint64Value(body map[string]interface{}, keys ...string) (uint64, bool) {
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case float64:
if val < 0 {
return 0, false
}
return uint64(val), true
case int:
if val < 0 {
return 0, false
}
return uint64(val), true
case nil:
return 0, true
case string:
trimmed := strings.TrimSpace(val)
if trimmed == "" {
return 0, true
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
return parsed, err == nil
}
}
c.ensureFormParsed()
if val := c.GetString(key); val != "" {
parsed, err := strconv.ParseUint(strings.TrimSpace(val), 10, 64)
return parsed, err == nil
}
}
return 0, false
}
func (c *BackendOrganizationController) getBoolValue(body map[string]interface{}, keys ...string) (bool, bool) {
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case bool:
return val, true
case float64:
return val != 0, true
case string:
trimmed := strings.ToLower(strings.TrimSpace(val))
switch trimmed {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off", "":
return false, true
}
}
}
c.ensureFormParsed()
if val := c.GetString(key); val != "" {
switch strings.ToLower(strings.TrimSpace(val)) {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off":
return false, true
}
}
}
return false, false
}
// getUint64Slice 解析 ID 数组,兼容 JSON 数组与逗号分隔字符串。
func (c *BackendOrganizationController) getUint64Slice(body map[string]interface{}, keys ...string) []uint64 {
result := make([]uint64, 0)
seen := map[uint64]bool{}
appendID := func(id uint64) {
if id == 0 || seen[id] {
return
}
seen[id] = true
result = append(result, id)
}
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case []interface{}:
for _, item := range val {
switch num := item.(type) {
case float64:
appendID(uint64(num))
case string:
if parsed, err := strconv.ParseUint(strings.TrimSpace(num), 10, 64); err == nil {
appendID(parsed)
}
}
}
case string:
for _, part := range strings.Split(val, ",") {
if parsed, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64); err == nil {
appendID(parsed)
}
}
case float64:
appendID(uint64(val))
}
}
if len(result) > 0 {
return result
}
c.ensureFormParsed()
if raw := c.GetString(key); raw != "" {
for _, part := range strings.Split(raw, ",") {
if parsed, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64); err == nil {
appendID(parsed)
}
}
}
if len(result) > 0 {
return result
}
}
return result
}
func (c *BackendOrganizationController) getUintSlice(body map[string]interface{}, keys ...string) []uint {
ids := c.getUint64Slice(body, keys...)
result := make([]uint, 0, len(ids))
for _, id := range ids {
result = append(result, uint(id))
}
return result
}
func (c *BackendOrganizationController) pathUint(name string) (uint, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64)
return uint(id), err == nil && id > 0
}
func (c *BackendOrganizationController) pathUint64(name string) (uint64, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64)
return id, err == nil && id > 0
}
func (c *BackendOrganizationController) nowString() string {
return time.Now().Format("2006-01-02 15:04:05")
}
// ---------------------------------------------------------------------------
// 与 ORM 交互的通用小工具
// ---------------------------------------------------------------------------
func strPtrIfNotEmpty(v string) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return &v
}
// nullableString 空字符串写入 NULL,便于统一区分“未填写”与“空值”。
func nullableString(v string) interface{} {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return v
}
func nullableUint64(v uint64) interface{} {
if v == 0 {
return nil
}
return v
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func parseDatePtr(v string) *time.Time {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
if t, err := time.Parse("2006-01-02", v); err == nil {
return &t
}
if t, err := time.Parse("2006-01-02 15:04:05", v); err == nil {
return &t
}
return nil
}
@@ -0,0 +1,405 @@
package controllers
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// 本文件承载组织架构模块的设置读写、CSV 导入导出,以及 DTO 组装与通用工具函数。
// ---------------------------------------------------------------------------
// 组织架构设置(按租户存放在 yz_backend_normal_setting 中)
// ---------------------------------------------------------------------------
// GetOrgSettings 获取当前租户的组织架构设置。
// GET /backend/{erp|oa}/getOrgSettings
func (c *BackendOrganizationController) GetOrgSettings() {
tid, ok := c.tenantID()
if !ok {
return
}
c.jsonOK(c.loadOrgSettings(tid))
}
// SaveOrgSettings 保存当前租户的组织架构设置。
// POST /backend/{erp|oa}/saveOrgSettings
func (c *BackendOrganizationController) SaveOrgSettings() {
tid, ok := c.tenantID()
if !ok {
return
}
body := c.parseJSONBody()
settings := c.loadOrgSettings(tid)
if v, has := c.getStringValue(body, "org_code_prefix", "code_prefix"); has {
settings.OrgCodePrefix = strings.TrimSpace(v)
}
if v, has := c.getStringValue(body, "employee_code_prefix"); has {
settings.EmployeeCodePrefix = strings.TrimSpace(v)
}
if v, has := c.getStringValue(body, "position_code_prefix"); has {
settings.PositionCodePrefix = strings.TrimSpace(v)
}
if v, has := c.getBoolValue(body, "auto_generate_codes", "auto_generate_code"); has {
settings.AutoGenerateCodes = v
}
if v, has := c.getIntValue(body, "code_length"); has {
settings.CodeLength = clampInt(v, 4, 32)
}
if v, has := c.getIntValue(body, "default_org_type"); has {
settings.DefaultOrgType = v
}
if v, has := c.getIntValue(body, "default_sort"); has {
settings.DefaultSort = maxInt(v, 0)
}
if v, has := c.getIntValue(body, "default_status"); has {
settings.DefaultStatus = v
}
if v, has := c.getIntValue(body, "max_org_levels", "max_level"); has {
settings.MaxOrgLevels = clampInt(v, 1, 32)
}
if v, has := c.getIntValue(body, "max_org_children"); has {
settings.MaxOrgChildren = clampInt(v, 1, 1000)
}
if v, has := c.getBoolValue(body, "allow_duplicate_codes"); has {
settings.AllowDuplicateCode = v
}
if v, has := c.getBoolValue(body, "batch_operations"); has {
settings.BatchOperations = v
}
if v, has := c.getBoolValue(body, "export_enabled"); has {
settings.ExportEnabled = v
}
if v, has := c.getBoolValue(body, "import_enabled"); has {
settings.ImportEnabled = v
}
if err := c.persistOrgSettings(tid, settings); err != nil {
c.jsonError(500, "保存组织设置失败: "+err.Error())
return
}
c.jsonOK(settings)
}
func (c *BackendOrganizationController) orgSettingsCode(tid uint64) string {
return fmt.Sprintf("%s_%d", orgSettingsCodePrefix, tid)
}
// loadOrgSettings 读取租户设置;无记录或解析失败时回退到默认值,保证接口始终可用。
func (c *BackendOrganizationController) loadOrgSettings(tid uint64) orgSettings {
settings := defaultOrgSettings()
var row models.BackendNormalSetting
err := models.Orm.QueryTable(new(models.BackendNormalSetting)).
Filter("code", c.orgSettingsCode(tid)).
Filter("delete_time__isnull", true).
One(&row)
if err != nil || strings.TrimSpace(row.Value) == "" {
return settings
}
if err := json.Unmarshal([]byte(row.Value), &settings); err != nil {
return defaultOrgSettings()
}
if settings.CodeLength <= 0 {
settings.CodeLength = 8
}
return settings
}
func (c *BackendOrganizationController) persistOrgSettings(tid uint64, settings orgSettings) error {
raw, err := json.Marshal(settings)
if err != nil {
return err
}
code := c.orgSettingsCode(tid)
var row models.BackendNormalSetting
err = models.Orm.QueryTable(new(models.BackendNormalSetting)).
Filter("code", code).
Filter("delete_time__isnull", true).
One(&row)
if err == nil {
now := time.Now()
row.Value = string(raw)
row.UpdateTime = &now
_, err = models.Orm.Update(&row, "value", "update_time")
return err
}
row = models.BackendNormalSetting{
Name: "组织架构设置",
Code: code,
Value: string(raw),
Remark: fmt.Sprintf("租户 %d 的组织架构设置", tid),
}
_, err = models.Orm.Insert(&row)
return err
}
// ---------------------------------------------------------------------------
// 导入 / 导出(CSV,带 UTF-8 BOM,Excel 可直接打开)
// ---------------------------------------------------------------------------
var organizationExportHeader = []string{
"组织编码", "组织名称", "上级组织编码", "是否公司(1是0否)", "排序", "状态(1启用0禁用)", "备注",
}
// ExportOrganization 导出当前租户组织架构为 CSV。
// GET /backend/{erp|oa}/exportOrganization
func (c *BackendOrganizationController) ExportOrganization() {
tid, ok := c.tenantID()
if !ok {
return
}
if !c.loadOrgSettings(tid).ExportEnabled {
c.jsonError(400, "导出功能已关闭")
return
}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).OrderBy("sort", "id").All(&rows); err != nil {
c.jsonError(500, "导出组织架构失败: "+err.Error())
return
}
codeByID := map[uint64]string{}
for _, row := range rows {
codeByID[row.ID] = row.OrgCode
}
c.Ctx.Output.Header("Content-Type", "text/csv; charset=utf-8")
c.Ctx.Output.Header("Content-Disposition",
fmt.Sprintf("attachment; filename=organization_%s.csv", time.Now().Format("20060102150405")))
// UTF-8 BOM,避免 Excel 打开中文乱码
_, _ = c.Ctx.ResponseWriter.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(c.Ctx.ResponseWriter)
_ = writer.Write(organizationExportHeader)
for _, row := range rows {
_ = writer.Write([]string{
row.OrgCode,
row.OrgName,
codeByID[row.ParentID],
strconv.Itoa(row.IsCompany),
strconv.FormatUint(uint64(row.Sort), 10),
strconv.Itoa(int(row.Status)),
derefString(row.Remark),
})
}
writer.Flush()
}
// ImportOrganization 从 CSV 导入组织架构。
// 已存在的组织编码执行更新,不存在的新增;上级组织通过编码关联,
// 上级关系在所有行入库后统一回填,因此 CSV 行序不影响结果。
// POST /backend/{erp|oa}/importOrganization (multipart/form-data, field=file)
func (c *BackendOrganizationController) ImportOrganization() {
tid, ok := c.tenantID()
if !ok {
return
}
if !c.loadOrgSettings(tid).ImportEnabled {
c.jsonError(400, "导入功能已关闭")
return
}
file, _, err := c.GetFile("file")
if err != nil {
c.jsonError(400, "请上传 CSV 文件")
return
}
defer file.Close()
reader := csv.NewReader(newBOMTrimReader(file))
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
c.jsonError(400, "解析 CSV 失败: "+err.Error())
return
}
if len(records) <= 1 {
c.jsonError(400, "CSV 中没有可导入的数据")
return
}
idByCode := map[string]uint64{}
var existing []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&existing); err != nil {
c.jsonError(500, "读取已有组织失败: "+err.Error())
return
}
for _, row := range existing {
idByCode[row.OrgCode] = row.ID
}
type pendingParent struct {
code string
parentCode string
}
created, updated := 0, 0
failures := make([]string, 0)
pending := make([]pendingParent, 0, len(records))
for i, record := range records[1:] {
lineNo := i + 2
if len(record) < 2 {
failures = append(failures, fmt.Sprintf("第 %d 行:列数不足", lineNo))
continue
}
orgCode := strings.TrimSpace(record[0])
orgName := strings.TrimSpace(record[1])
if orgCode == "" || orgName == "" {
failures = append(failures, fmt.Sprintf("第 %d 行:组织编码与名称不能为空", lineNo))
continue
}
parentCode := csvField(record, 2)
isCompany := csvInt(record, 3, 0)
sortVal := csvInt(record, 4, 0)
status := csvInt(record, 5, 1)
remark := csvField(record, 6)
if id, exists := idByCode[orgCode]; exists {
update := orm.Params{
"org_name": orgName,
"is_company": isCompany,
"sort": uint(maxInt(sortVal, 0)),
"status": int8(status),
"remark": nullableString(remark),
}
if _, err := c.orgQuery(tid).Filter("id", id).Update(update); err != nil {
failures = append(failures, fmt.Sprintf("第 %d 行:更新失败 %s", lineNo, err.Error()))
continue
}
updated++
} else {
row := models.BackendOrganization{
Tid: tid,
OrgName: orgName,
OrgCode: orgCode,
IsCompany: isCompany,
Sort: uint(maxInt(sortVal, 0)),
Status: int8(status),
Remark: strPtrIfNotEmpty(remark),
}
id, err := models.Orm.Insert(&row)
if err != nil {
failures = append(failures, fmt.Sprintf("第 %d 行:创建失败 %s", lineNo, err.Error()))
continue
}
idByCode[orgCode] = uint64(id)
created++
}
pending = append(pending, pendingParent{code: orgCode, parentCode: parentCode})
}
for _, item := range pending {
selfID := idByCode[item.code]
if selfID == 0 {
continue
}
parentID := uint64(0)
if item.parentCode != "" {
parentID = idByCode[item.parentCode]
if parentID == 0 {
failures = append(failures, fmt.Sprintf("组织 %s:上级编码 %s 不存在", item.code, item.parentCode))
continue
}
if parentID == selfID {
failures = append(failures, fmt.Sprintf("组织 %s:上级不能是自己", item.code))
continue
}
}
_, _ = c.orgQuery(tid).Filter("id", selfID).
Update(orm.Params{"parent_id": parentID, "is_company": boolInt(parentID == 0)})
}
c.jsonOK(map[string]interface{}{
"created": created,
"updated": updated,
"failed": len(failures),
"failures": failures,
})
}
// GetImportTemplate 下载导入模板(仅表头 + 一行示例)。
// GET /backend/{erp|oa}/organizationImportTemplate
func (c *BackendOrganizationController) GetImportTemplate() {
if _, ok := c.tenantID(); !ok {
return
}
c.Ctx.Output.Header("Content-Type", "text/csv; charset=utf-8")
c.Ctx.Output.Header("Content-Disposition", "attachment; filename=organization_template.csv")
_, _ = c.Ctx.ResponseWriter.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(c.Ctx.ResponseWriter)
_ = writer.Write(organizationExportHeader)
_ = writer.Write([]string{"COM001", "示例总公司", "", "1", "0", "1", "顶级组织,上级编码留空"})
_ = writer.Write([]string{"DEP001", "示例研发部", "COM001", "0", "1", "1", "隶属 COM001"})
writer.Flush()
}
// bomTrimReader 去掉 CSV 文件开头可能存在的 UTF-8 BOM。
type bomTrimReader struct {
reader io.Reader
checked bool
buf []byte
}
func newBOMTrimReader(r io.Reader) io.Reader {
return &bomTrimReader{reader: r}
}
func (r *bomTrimReader) Read(p []byte) (int, error) {
if !r.checked {
r.checked = true
head := make([]byte, 3)
n, err := io.ReadFull(r.reader, head)
if n == 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF {
r.buf = nil
} else {
r.buf = head[:n]
}
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return 0, err
}
}
if len(r.buf) > 0 {
n := copy(p, r.buf)
r.buf = r.buf[n:]
return n, nil
}
return r.reader.Read(p)
}
func csvField(record []string, index int) string {
if index >= len(record) {
return ""
}
return strings.TrimSpace(record[index])
}
func csvInt(record []string, index int, fallback int) int {
raw := csvField(record, index)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return v
}
+185
View File
@@ -0,0 +1,185 @@
package controllers
import (
"io"
"strings"
"testing"
)
// 这些用例只覆盖组织架构模块中不依赖数据库的纯函数:树组装、层级计算、CSV 解析与编码生成。
func TestBuildOrganizationTree(t *testing.T) {
list := []organizationDTO{
{ID: 1, OrgName: "总公司", ParentID: 0},
{ID: 2, OrgName: "研发部", ParentID: 1},
{ID: 3, OrgName: "前端组", ParentID: 2},
{ID: 4, OrgName: "孤儿部门", ParentID: 999}, // 上级不在列表中,应作为根节点保留
}
tree := buildOrganizationTree(list)
if len(tree) != 2 {
t.Fatalf("期望 2 个根节点,实际 %d", len(tree))
}
root := tree[0]
if root["org_name"] != "总公司" {
t.Fatalf("第一个根节点应为总公司,实际 %v", root["org_name"])
}
children, ok := root["children"].([]map[string]interface{})
if !ok || len(children) != 1 {
t.Fatalf("总公司应有 1 个子节点,实际 %v", root["children"])
}
grandChildren, ok := children[0]["children"].([]map[string]interface{})
if !ok || len(grandChildren) != 1 || grandChildren[0]["org_name"] != "前端组" {
t.Fatalf("研发部下应有前端组,实际 %v", children[0]["children"])
}
if tree[1]["org_name"] != "孤儿部门" {
t.Fatalf("上级缺失的节点应作为根节点保留,实际 %v", tree[1]["org_name"])
}
}
func TestTreeDepth(t *testing.T) {
childrenOf := map[uint64][]uint64{
0: {1},
1: {2, 3},
2: {4},
}
if got := treeDepth(childrenOf, 0, 0); got != 3 {
t.Fatalf("期望深度 3,实际 %d", got)
}
if got := treeDepth(map[uint64][]uint64{}, 0, 0); got != 0 {
t.Fatalf("空树深度应为 0,实际 %d", got)
}
}
func TestSubtreeHeightFrom(t *testing.T) {
childrenOf := map[uint64][]uint64{
1: {2, 3},
2: {4},
}
if got := subtreeHeightFrom(childrenOf, 1, 0); got != 3 {
t.Fatalf("以 1 为根的子树高度应为 3,实际 %d", got)
}
if got := subtreeHeightFrom(childrenOf, 4, 0); got != 1 {
t.Fatalf("叶子节点高度应为 1,实际 %d", got)
}
}
func TestCSVHelpers(t *testing.T) {
record := []string{" COM001 ", "总公司", "", "1"}
if got := csvField(record, 0); got != "COM001" {
t.Fatalf("csvField 应去掉空格,实际 %q", got)
}
if got := csvField(record, 9); got != "" {
t.Fatalf("越界应返回空串,实际 %q", got)
}
if got := csvInt(record, 3, 0); got != 1 {
t.Fatalf("csvInt 应解析出 1,实际 %d", got)
}
if got := csvInt(record, 2, 7); got != 7 {
t.Fatalf("空值应返回默认值 7,实际 %d", got)
}
if got := csvInt(record, 1, 5); got != 5 {
t.Fatalf("非数字应返回默认值 5,实际 %d", got)
}
}
func TestBOMTrimReader(t *testing.T) {
withBOM := append([]byte{0xEF, 0xBB, 0xBF}, []byte("编码,名称\n")...)
got, err := io.ReadAll(newBOMTrimReader(strings.NewReader(string(withBOM))))
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if string(got) != "编码,名称\n" {
t.Fatalf("BOM 未被去掉,实际 %q", string(got))
}
got, err = io.ReadAll(newBOMTrimReader(strings.NewReader("编码,名称\n")))
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if string(got) != "编码,名称\n" {
t.Fatalf("无 BOM 时内容被破坏,实际 %q", string(got))
}
// 内容短于 3 字节时不能丢数据
got, err = io.ReadAll(newBOMTrimReader(strings.NewReader("ab")))
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if string(got) != "ab" {
t.Fatalf("短内容被破坏,实际 %q", string(got))
}
}
func TestGenerateCode(t *testing.T) {
c := &BackendOrganizationController{}
code := c.generateCode("ORG", 8)
if len(code) != 8 || !strings.HasPrefix(code, "ORG") {
t.Fatalf("期望 8 位且以 ORG 开头,实际 %q", code)
}
// 长度限制小于前缀长度时不截断前缀
code = c.generateCode("PREFIX", 3)
if !strings.HasPrefix(code, "PREFIX") {
t.Fatalf("前缀不应被截断,实际 %q", code)
}
// 不限制长度时返回完整前缀 + 时间戳
code = c.generateCode("EMP", 0)
if len(code) != len("EMP")+14 {
t.Fatalf("未限制长度时应为前缀加 14 位时间戳,实际 %q", code)
}
}
func TestUniqueMessageAndClamp(t *testing.T) {
if got := uniqueMessage(true, "编码"); got != "编码可用" {
t.Fatalf("实际 %q", got)
}
if got := uniqueMessage(false, "账号"); got != "账号已存在" {
t.Fatalf("实际 %q", got)
}
if got := clampInt(0, 4, 32); got != 4 {
t.Fatalf("下界钳制失败,实际 %d", got)
}
if got := clampInt(99, 4, 32); got != 32 {
t.Fatalf("上界钳制失败,实际 %d", got)
}
if got := clampInt(10, 4, 32); got != 10 {
t.Fatalf("区间内不应改变,实际 %d", got)
}
}
func TestHashEmployeePassword(t *testing.T) {
if got := hashEmployeePassword(" "); got != "" {
t.Fatalf("空密码应返回空串,实际 %q", got)
}
got := hashEmployeePassword("secret123")
if len(got) != 64 {
t.Fatalf("sha256 hex 应为 64 位,实际 %d", len(got))
}
if got != hashEmployeePassword("secret123") {
t.Fatal("相同输入应得到相同结果")
}
}
func TestOrgNameByIDString(t *testing.T) {
nameByID := map[uint64]string{7: "研发部"}
if got := orgNameByIDString(nameByID, "7"); got != "研发部" {
t.Fatalf("实际 %q", got)
}
if got := orgNameByIDString(nameByID, ""); got != "" {
t.Fatalf("空值应返回空串,实际 %q", got)
}
if got := orgNameByIDString(nameByID, "abc"); got != "" {
t.Fatalf("非数字应返回空串,实际 %q", got)
}
if got := orgNameByIDString(nameByID, "99"); got != "" {
t.Fatalf("未命中应返回空串,实际 %q", got)
}
}