优化项目管理

This commit is contained in:
2026-09-13 22:31:42 +08:00
parent 2f6cdbb3cd
commit a8c09c1db5
30 changed files with 1654 additions and 346 deletions
+109 -22
View File
@@ -47,7 +47,6 @@ type contractPayload struct {
SignDate string `json:"sign_date"`
EffectiveDate string `json:"effective_date"`
ExpireDate string `json:"expire_date"`
TechDate string `json:"tech_date"`
Parties json.RawMessage `json:"parties"`
Products json.RawMessage `json:"products"`
Step int8 `json:"step"`
@@ -87,6 +86,8 @@ func (c *BackendCrmContractController) List() {
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
// 签约主体名称存储在 parties JSON 中,用 LIKE 一并匹配
kw := orm.NewCondition().
@@ -118,6 +119,20 @@ func (c *BackendCrmContractController) List() {
cond = cond.And("project_id", pid)
}
}
// 周期筛选:与顶部统计同口径(合同有效期 [生效日期, 结束日期] 与所选周期重叠),
// period=month/quarter/year 取当期区间,period=custom 用 start_date / end_date
period := strings.TrimSpace(c.GetString("period"))
rangeStart := strings.TrimSpace(c.GetString("start_date"))
rangeEnd := strings.TrimSpace(c.GetString("end_date"))
if period != "" && period != "custom" {
rangeStart, rangeEnd = periodRange(period)
}
if rangeStart != "" {
cond = cond.AndCond(orm.NewCondition().Or("expire_date__isnull", true).Or("expire_date__gte", rangeStart))
}
if rangeEnd != "" {
cond = cond.AndCond(orm.NewCondition().Or("effective_date__isnull", true).Or("effective_date__lte", rangeEnd))
}
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
total, _ := qs.Count()
@@ -127,7 +142,6 @@ func (c *BackendCrmContractController) List() {
}
items := make([]contractResp, 0, len(list))
for i := range list {
c.syncContractExceptionStatus(&list[i])
items = append(items, buildContractResp(&list[i]))
}
pipelineOk(&c.Controller, map[string]interface{}{
@@ -185,7 +199,8 @@ func (c *BackendCrmContractController) Stats() {
})
}
// periodRange 根据 period 返回 [start, end] 签约日期区间(YYYY-MM-DD):month/quarter/year 取当期。
// periodRange 根据 period 返回当期区间 [start, end](YYYY-MM-DD):month/quarter/year。
// 统计与列表均以该区间与合同有效期比较(有效期与周期重叠即命中)。
func periodRange(period string) (string, string) {
now := time.Now()
loc := now.Location()
@@ -226,7 +241,11 @@ func (c *BackendCrmContractController) Detail() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
c.syncContractExceptionStatus(&row)
// 数据范围校验:非管理员仅能查看本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权查看该合同数据")
return
}
pipelineOk(&c.Controller, buildContractResp(&row))
}
@@ -262,7 +281,6 @@ func (c *BackendCrmContractController) Create() {
SignDate: parsePipelineDate(p.SignDate),
EffectiveDate: parsePipelineDate(p.EffectiveDate),
ExpireDate: parsePipelineDate(p.ExpireDate),
TechDate: parsePipelineDate(p.TechDate),
Status: pickInt8(p.Status, 1, 5),
Step: pickInt8(p.Step, 1, 2),
Remark: p.Remark,
@@ -310,6 +328,12 @@ func (c *BackendCrmContractController) Create() {
}
applyContractAmounts(&row)
// 唯一性校验:同租户内「合同名称 + 签约方」完全一致才视为重复。
// 同名但签约方不同(例如我方分别作为甲方 / 乙方的两份合同)不算重复。
if crmContractDuplicated(tenantID, row.ContractName, row.Parties) {
pipelineErr(&c.Controller, 409, 409, "已存在同名且签约方相同的合同(可能由其他同事创建),如需继续跟进请联系管理员转移")
return
}
if _, err := models.Orm.Insert(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
@@ -345,6 +369,11 @@ func (c *BackendCrmContractController) Update() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
return
}
if strings.TrimSpace(p.ContractName) == "" {
pipelineErr(&c.Controller, 400, 400, "合同名称不能为空")
return
@@ -385,7 +414,6 @@ func (c *BackendCrmContractController) Update() {
row.SignDate = parsePipelineDate(p.SignDate)
row.EffectiveDate = parsePipelineDate(p.EffectiveDate)
row.ExpireDate = parsePipelineDate(p.ExpireDate)
row.TechDate = parsePipelineDate(p.TechDate)
if p.Parties != nil {
parties, err := normalizeContractJSON(p.Parties)
if err != nil {
@@ -447,6 +475,11 @@ func (c *BackendCrmContractController) Delete() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
return
}
if !canDeleteCrmRecord(claims, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该合同")
return
@@ -497,6 +530,11 @@ func (c *BackendCrmContractController) ChangeStatus() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
return
}
if row.Status == p.Status {
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": row.Status})
return
@@ -684,25 +722,74 @@ func contractReceivedAmount(tenantID string, contractID uint64) float64 {
return toFloat64(rows[0]["received"])
}
// syncContractExceptionStatus 履约中合同状态对账:技术日期已过但回款未回完(已收 < 合同总额,
// 允许 0.005 浮点容差)时,自动流转为「执行异常」(5)。无技术日期或不满足翻转条件则不动。
// 在列表 / 详情读取时触发,也可在回款登记后调用,使状态及时反映。
func (c *BackendCrmContractController) syncContractExceptionStatus(row *models.TenantCrmContract) {
if row == nil || row.Status != 4 || row.TechDate == nil || row.ID == 0 {
return
// crmContractDuplicated 判断同租户内是否已存在「合同名称 + 签约方」均一致的合同。
//
// 判重口径:合同名称一致,且参与方(甲/乙/丙/丁)归一化签名一致。
// 因此同一名称下「我方作为甲方」与「我方作为乙方」的两份合同不算重复。
func crmContractDuplicated(tenantID, name, partiesJSON string) bool {
name = strings.TrimSpace(name)
if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
return false
}
now := time.Now()
if !row.TechDate.Before(now) {
return
sig := contractPartiesSignature(partiesJSON)
var rows []models.TenantCrmContract
_, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).
Filter("contract_name", name).
All(&rows, "id", "parties")
if err != nil {
return false
}
received := contractReceivedAmount(row.TenantID, row.ID)
if row.TotalAmount > 0 && received+0.005 < row.TotalAmount {
row.Status = 5
row.UpdateTime = now
_, _ = models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", row.ID).Filter("tenant_id", row.TenantID).
Update(orm.Params{"status": int8(5), "update_time": now})
for i := range rows {
if contractPartiesSignature(rows[i].Parties) == sig {
return true
}
}
return false
}
// contractPartiesSignature 参与方归一化签名:按甲/乙/丙/丁固定顺序拼接「角色:主体类型|主体」。
// 主体优先取关联主体 ID(ref_id),无 ID(手工录入名称)时退化为名称(ref_name);
// 参与方为空或解析失败时返回空串,此时判重退化为「仅合同名称一致」。
func contractPartiesSignature(partiesJSON string) string {
if strings.TrimSpace(partiesJSON) == "" {
return ""
}
var parties []map[string]interface{}
if err := json.Unmarshal([]byte(partiesJSON), &parties); err != nil {
return ""
}
str := func(v interface{}) string {
if v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
byRole := make(map[string]string, len(parties))
for _, p := range parties {
role := str(p["role"])
if role == "" {
continue
}
subject := str(p["ref_id"])
// ref_id 为 0 / 空表示未关联主体库(本公司或手工录入),此时用名称兜底,
// 避免不同主体都归到 "0" 造成误判为重复
if subject == "" || subject == "0" {
subject = str(p["ref_name"])
}
byRole[role] = str(p["ref_type"]) + "|" + subject
}
var sb strings.Builder
for _, role := range []string{"party_a", "party_b", "party_c", "party_d"} {
if v, ok := byRole[role]; ok {
sb.WriteString(role)
sb.WriteString(":")
sb.WriteString(v)
sb.WriteString(";")
}
}
return sb.String()
}
// genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。