406 lines
11 KiB
Go
406 lines
11 KiB
Go
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
|
||
}
|