增加组织架构

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
@@ -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
}