优化项目管理
This commit is contained in:
@@ -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 次。
|
||||
|
||||
Reference in New Issue
Block a user