批量更新功能

This commit is contained in:
2026-09-14 16:15:44 +08:00
parent 6f106e43af
commit a26c98b92b
79 changed files with 4529 additions and 724 deletions
+268 -25
View File
@@ -21,15 +21,37 @@ type BackendCrmProjectController struct {
beego.Controller
}
// crmProjectStatusDone 项目状态「已完成」(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常):
// 列表排列时「已完成」沉底展示,与前端筛选传参一致使用字符串值。
const crmProjectStatusDone = "3"
// projectStatusName 项目状态文案(用于操作日志)。
func projectStatusName(s int8) string {
switch s {
case 1:
return "未开始"
case 2:
return "进行中"
case 3:
return "已完成"
case 4:
return "已暂停"
case 5:
return "异常"
}
return "未知"
}
type projectPayload struct {
ProjectName string `json:"project_name"`
ProjectNo string `json:"project_no"`
// 项目类型:1=单一项目(仅上游客户) 2=上下游项目(上游客户 + 下游供应商)
// 3=上中游项目(上游 A 公司 + 中游 B 公司,下游固定为本公司)
ProjectType int8 `json:"project_type"`
CustomerID uint64 `json:"customer_id"`
CustomerName string `json:"customer_name"`
SupplierID uint64 `json:"supplier_id"`
SupplierRefType int8 `json:"supplier_ref_type"` // 下游主体来源:1客户 2供应商
SupplierRefType int8 `json:"supplier_ref_type"` // 对方主体来源:1客户 2供应商(下游 / 中游)
SupplierName string `json:"supplier_name"`
SupplierIndustry string `json:"supplier_industry"`
OwnerUserID string `json:"owner_user_id"`
@@ -57,12 +79,13 @@ type projectPayload struct {
}
// normalizeProjectFields 归一项目类型、上下游金额与上下游对接人:
// - 项目类型仅识别 2=上下游,其余一律按 1=单一项目处理;
// - 项目类型仅识别 2=上下游、3=上中游,其余一律按 1=单一项目处理;
// - 上游金额 / 上游对接人缺省时回退旧的 amount / contact_* 字段(兼容未升级的调用方);
// - 单一项目不存在下游,下游金额与下游对接人一并清空。
// - 单一项目不存在上下游,下游金额与下游对接人一并清空;
// - 3=上中游项目(上游 A 公司 + 中游 B 公司,下游固定为本公司)的中游主体复用下游字段存储。
func normalizeProjectFields(p *projectPayload) (pt int8, upstream, downstream float64, upContact, upPhone, downContact, downPhone string) {
pt = p.ProjectType
if pt != 2 {
if pt != 2 && pt != 3 {
pt = 1
}
upstream = p.UpstreamAmount
@@ -71,7 +94,8 @@ func normalizeProjectFields(p *projectPayload) (pt int8, upstream, downstream fl
}
upContact = strings.TrimSpace(firstNonEmpty(p.UpstreamContact, p.ContactPerson))
upPhone = strings.TrimSpace(firstNonEmpty(p.UpstreamPhone, p.ContactPhone))
if pt == 2 {
// 上下游项目:对方为下游(供应商/客户);上中游项目:对方为中游(中间方)——两者均落在 downstream_* 字段
if pt == 2 || pt == 3 {
downstream = p.DownstreamAmount
downContact = strings.TrimSpace(p.DownstreamContact)
downPhone = strings.TrimSpace(p.DownstreamPhone)
@@ -179,29 +203,78 @@ func (c *BackendCrmProjectController) List() {
ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
kw := orm.NewCondition().
Or("project_name__contains", keyword).
Or("project_no__contains", keyword).
Or("customer_name__contains", keyword).
Or("supplier_name__contains", keyword)
cond = cond.AndCond(kw)
}
if status != "" {
cond = cond.And("status", status)
}
if ownerID != "" {
cond = cond.And("owner_user_id", ownerID)
}
qs := models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond)
total, _ := qs.Count()
// buildCond 构造本次列表的筛选条件。
// 「已完成」沉底排列需要按「未完成 / 已完成」两组分别查询,因此这里做成可重复调用的构造方法。
buildCond := func() *orm.Condition {
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
kw := orm.NewCondition().
Or("project_name__contains", keyword).
Or("project_no__contains", keyword).
Or("customer_name__contains", keyword).
Or("supplier_name__contains", keyword)
cond = cond.AndCond(kw)
}
if status != "" {
cond = cond.And("status", status)
}
if ownerID != "" {
cond = cond.And("owner_user_id", ownerID)
}
return cond
}
projectQuery := func(cond *orm.Condition) orm.QuerySeter {
return models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond)
}
// 未完成组:除「已完成」外的全部状态(含历史数据中的空状态),沉底排列时取列表前段
unfinishedCond := func() *orm.Condition {
return buildCond().AndCond(orm.NewCondition().
Or("status__ne", crmProjectStatusDone).
Or("status__isnull", true))
}
total, _ := projectQuery(buildCond()).Count()
var list []models.TenantCrmProject
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
offset := (page - 1) * pageSize
if status != "" {
// 已按状态筛选:结果只含单一状态,直接按新→旧分页
if _, err := projectQuery(buildCond()).OrderBy("-id").Offset(offset).Limit(pageSize).All(&list); err != nil {
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
return
}
} else {
// 列表排列:未完成项目在前、「已完成」项目沉底,两组内部仍按新→旧。
// 先按两组数量把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
unfinished, _ := projectQuery(unfinishedCond()).Count()
unfinishedTotal := int(unfinished)
finishedTotal := int(total) - unfinishedTotal
end := offset + pageSize
if end > int(total) {
end = int(total)
}
aFrom, aTo := min(offset, unfinishedTotal), min(end, unfinishedTotal)
bFrom := min(max(offset-unfinishedTotal, 0), finishedTotal)
bTo := min(max(end-unfinishedTotal, 0), finishedTotal)
if aTo > aFrom {
if _, err := projectQuery(unfinishedCond()).OrderBy("-id").Offset(aFrom).Limit(aTo - aFrom).All(&list); err != nil {
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
return
}
}
if bTo > bFrom {
var finished []models.TenantCrmProject
if _, err := projectQuery(buildCond().And("status", crmProjectStatusDone)).
OrderBy("-id").Offset(bFrom).Limit(bTo - bFrom).All(&finished); err != nil {
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
return
}
list = append(list, finished...)
}
}
}
attachBusinessName(tenantID, list)
// 项目起止时间由关联合同汇总(不再依赖手工维护的开始 / 结束日期)
@@ -495,6 +568,176 @@ func (c *BackendCrmProjectController) Delete() {
pipelineOk(&c.Controller, nil)
}
// ========================== 项目联系人(从对应企业通讯录挑选) ==========================
// contactIDsJSON 把联系人ID数组序列化为 JSON 字符串;空集合返回空串。
func contactIDsJSON(ids []uint64) string {
if len(ids) == 0 {
return ""
}
b, err := json.Marshal(ids)
if err != nil {
return ""
}
return string(b)
}
// filterCompanyContactIDs 过滤出确实属于指定企业通讯录的联系人ID(防止跨企业/脏ID混入)。
// companyID 为空或 ids 为空时直接返回空集合。
func filterCompanyContactIDs(tenantID string, companyType string, companyID *uint64, ids []uint64) []uint64 {
if companyID == nil || *companyID == 0 || len(ids) == 0 {
return nil
}
var rows []models.ErpCompanyContact
_, _ = models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("tenant_id", tenantID).
Filter("company_type", companyType).
Filter("company_id", *companyID).
Filter("delete_time__isnull", true).
All(&rows)
allowed := make(map[uint64]bool, len(rows))
for _, r := range rows {
allowed[r.ID] = true
}
out := make([]uint64, 0, len(ids))
seen := make(map[uint64]bool, len(ids))
for _, id := range ids {
if id > 0 && allowed[id] && !seen[id] {
out = append(out, id)
seen[id] = true
}
}
return out
}
// SaveContacts POST /backend/crm/project/:id/contacts
// 保存项目联系人:从对应企业通讯录中挑选的联系人ID集合(支持多选)。
// - 上游=客户方(customer_id 对应通讯录);
// - 下游/中游=对方主体方(supplier_ref_type:1=客户库 2=供应商库,supplier_id 对应通讯录);
// - 单一项目(project_type=1)只保留上游集合,下游集合自动清空;
// - 传入不属于对应企业的联系人ID会被过滤掉。
func (c *BackendCrmProjectController) SaveContacts() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
UpstreamContactIDs []uint64 `json:"upstream_contact_ids"`
DownstreamContactIDs []uint64 `json:"downstream_contact_ids"`
}
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var proj models.TenantCrmProject
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&proj); err != nil {
pipelineErr(&c.Controller, 404, 404, "项目未找到")
return
}
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据")
return
}
pt := proj.ProjectType
if pt != 2 && pt != 3 {
pt = 1
}
upIDs := filterCompanyContactIDs(tenantID, "customer", proj.CustomerID, p.UpstreamContactIDs)
var downIDs []uint64
// 上下游 / 上中游项目:对方主体(下游 / 中游)联系人按 supplier_ref_type 选择对应库
if pt != 1 && proj.SupplierID != nil && *proj.SupplierID > 0 {
companyType := "supplier"
if proj.SupplierRefType == 1 {
companyType = "customer"
}
downIDs = filterCompanyContactIDs(tenantID, companyType, proj.SupplierID, p.DownstreamContactIDs)
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", proj.ID).Filter("tenant_id", tenantID).
Update(map[string]interface{}{
"upstream_contact_ids": contactIDsJSON(upIDs),
"downstream_contact_ids": contactIDsJSON(downIDs),
"update_time": now,
})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "保存项目联系人失败: "+err.Error())
return
}
crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目联系人:"+proj.ProjectName, claims)
pipelineOk(&c.Controller, map[string]interface{}{
"upstream_contact_ids": upIDs,
"downstream_contact_ids": downIDs,
})
}
// ChangeStatus POST /backend/crm/project/:id/status
// 项目状态流转:1=未开始 2=进行中 3=已完成 4=已暂停 5=异常。
// 各状态间可互切;仅更新状态字段,不触碰其他业务字段。
func (c *BackendCrmProjectController) ChangeStatus() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Status int8 `json:"status"`
}
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.Status < 1 || p.Status > 5 {
pipelineErr(&c.Controller, 400, 400, "无效的状态值")
return
}
tenantID := pipelineTenantID(claims)
var proj models.TenantCrmProject
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&proj); err != nil {
pipelineErr(&c.Controller, 404, 404, "项目未找到")
return
}
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据")
return
}
if proj.Status == p.Status {
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID, "status": proj.Status})
return
}
if _, err := models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(map[string]interface{}{"status": p.Status, "update_time": time.Now()}); err != nil {
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
return
}
crmWriteLog(tenantID, 3, proj.ID, "status",
"项目状态流转:"+projectStatusName(proj.Status)+" → "+projectStatusName(p.Status), claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID, "status": p.Status})
}
// ========================== 项目文档(OA 文档库绑定) ==========================
// projectDocItem 项目文档条目:文档库文档 + 所在文件夹名称。