增加组织架构
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user