Files
yunzerwebsiteallinone/go/controllers/backend_organization_support.go
T
2026-08-27 21:25:39 +08:00

406 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}