优化项目管理

This commit is contained in:
2026-09-14 17:30:12 +08:00
parent a26c98b92b
commit 4ab5effce6
10 changed files with 434 additions and 158 deletions
+15
View File
@@ -193,6 +193,17 @@ func (c *BackendCrmContractController) List() {
}
}
}
// 回款逾期日期(取最早,未收齐且已过计划回款日期):合同「逾期」标签与结束日期口径一并参考
if len(list) > 0 {
contractIDs := make([]uint64, 0, len(list))
for i := range list {
contractIDs = append(contractIDs, list[i].ID)
}
overdues := paybackOverdueDateByContracts(tenantID, contractIDs)
for i := range list {
list[i].PaybackOverdueDate = overdues[list[i].ID]
}
}
items := make([]contractResp, 0, len(list))
for i := range list {
items = append(items, buildContractResp(&list[i]))
@@ -303,6 +314,10 @@ func (c *BackendCrmContractController) Detail() {
pipelineErr(&c.Controller, 403, 403, "无权查看该合同数据")
return
}
// 回款逾期日期(取最早,未收齐且已过计划回款日期):合同「逾期」标签与结束日期口径一并参考
if od := paybackOverdueDateByContracts(pipelineTenantID(claims), []uint64{row.ID}); od[row.ID] != "" {
row.PaybackOverdueDate = od[row.ID]
}
pipelineOk(&c.Controller, buildContractResp(&row))
}
+125 -1
View File
@@ -229,6 +229,11 @@ func (c *BackendCrmPaybackController) Create() {
return
}
tenantID := pipelineTenantID(claims)
// 回款已全部收齐(进度 100%)的合同不允许再新建回款计划(与回款进度汇总口径一致)
if paybackContractPaidOff(tenantID, p.ContractID) {
pipelineErr(&c.Controller, 400, 400, "回款已全部完成(进度 100%),不可再新建回款计划")
return
}
itemsJSON, totalAmount, err := normalizePaybackItems(p.Items)
if err != nil {
@@ -308,6 +313,11 @@ func (c *BackendCrmPaybackController) Update() {
pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
return
}
// 已完成(回款已收齐)的回款计划不允许编辑,保证已收齐数据稳定
if row.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不允许编辑")
return
}
if p.ContractID > 0 {
row.ContractID = &p.ContractID
@@ -393,7 +403,7 @@ func (c *BackendCrmPaybackController) Delete() {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该回款计划")
return
}
// 已完成(回款已收齐)的回款计划不允许删除
// 已完成(回款已收齐)的回款计划不允许删除,避免破坏已收齐数据
if row.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不允许删除")
return
@@ -801,6 +811,11 @@ func (c *BackendCrmPaybackController) CreateRecord() {
pipelineErr(&c.Controller, 404, 404, "回款计划不存在")
return
}
// 计划已完成(回款已收齐)时不允许再新建回款记录
if plan.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不可再新建回款记录")
return
}
payMethod := p.PayMethod
if payMethod <= 0 {
payMethod = plan.PayMethod
@@ -950,6 +965,115 @@ func (c *BackendCrmPaybackController) UpdateRecord() {
})
}
// paybackOverdueDateByContracts 批量计算各合同的「回款逾期日期」(yyyy-MM-dd,取最早):
// 合同下全部未收齐回款计划中,存在「未收齐且已过计划回款日期」的明细时取最早日期;
// 与回款逾期联动(合同置「异常」)同一判定口径,供合同列表 / 详情的「逾期」标签一并参考。
func paybackOverdueDateByContracts(tenantID string, contractIDs []uint64) map[uint64]string {
out := make(map[uint64]string)
if models.Orm == nil || tenantID == "" || len(contractIDs) == 0 {
return out
}
today := time.Now().Format("2006-01-02")
var plans []models.TenantCrmPayback
if _, err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
Filter("tenant_id", tenantID).
Filter("contract_id__in", contractIDs).
Filter("delete_time__isnull", true).
Filter("status__ne", 2). // 已收齐(已完成)的计划跳过
All(&plans, "id", "contract_id", "items"); err != nil {
return out
}
if len(plans) == 0 {
return out
}
// 回款记录按「计划 + 明细序号」汇总(item_seq=0 视为整单登记,不计入任何明细)
planIDs := make([]uint64, 0, len(plans))
for i := range plans {
planIDs = append(planIDs, plans[i].ID)
}
var recs []models.TenantCrmPaybackRecord
_, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
Filter("payback_id__in", planIDs).
Filter("delete_time__isnull", true).
All(&recs, "payback_id", "item_seq", "amount")
receivedByPlan := make(map[uint64]map[int]float64, len(plans))
for _, r := range recs {
if r.ItemSeq <= 0 {
continue
}
m := receivedByPlan[r.PaybackID]
if m == nil {
m = make(map[int]float64)
receivedByPlan[r.PaybackID] = m
}
m[r.ItemSeq] += r.Amount
}
for i := range plans {
plan := plans[i]
if plan.ContractID == nil || *plan.ContractID == 0 {
continue
}
date := paybackPlanEarliestOverdueDate(plan, receivedByPlan[plan.ID], today)
if date == "" {
continue
}
cid := *plan.ContractID
if cur, ok := out[cid]; !ok || date < cur {
out[cid] = date
}
}
return out
}
// paybackPlanEarliestOverdueDate 计划明细中最早的「未收齐且已过计划回款日期」的日期;无逾期返回空串。
func paybackPlanEarliestOverdueDate(plan models.TenantCrmPayback, receivedBySeq map[int]float64, today string) string {
items := parsePaybackItems(plan.Items)
if len(items) == 0 {
return ""
}
earliest := ""
for _, it := range items {
received := receivedBySeq[it.Seq]
if it.ReceivedAmount > received {
received = it.ReceivedAmount
}
if it.Amount > 0 && received >= it.Amount {
continue // 已收齐
}
planDate := strings.TrimSpace(it.PlanDate)
if len(planDate) > 10 {
planDate = planDate[:10]
}
if planDate == "" || planDate >= today {
continue
}
if earliest == "" || planDate < earliest {
earliest = planDate
}
}
return earliest
}
// paybackContractPaidOff 判断某合同的回款是否已全部收齐(进度 100%):
// 汇总该合同全部有效回款计划的计划总额与已回款金额,口径与回款进度列表的汇总一致。
// 已收齐时不允许再新建回款计划(避免收齐后继续追加,保证对账数据稳定)。
func paybackContractPaidOff(tenantID string, contractID uint64) bool {
if contractID == 0 {
return false
}
var plans []models.TenantCrmPayback
_, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).
Filter("tenant_id", tenantID).
Filter("contract_id", contractID).
Filter("delete_time__isnull", true).All(&plans)
var planned, received float64
for _, p := range plans {
planned = round2(planned + p.TotalAmount)
received = round2(received + p.ReceivedAmount)
}
return planned > 0 && received+0.005 >= planned
}
// refreshPlanReceived 按回款记录重算计划的已回款金额,并同步计划状态(1进行中 / 2已完成)。
func refreshPlanReceived(tenantID string, paybackID uint64) {
if paybackID == 0 {
+59 -29
View File
@@ -183,6 +183,27 @@ func attachProjectContractPeriod(tenantID string, list []models.TenantCrmProject
}
}
// crmProjectNameDuplicated 项目重名校验:同租户 + 同名 + 同一客户(服务主体)才算重复。
// 不同客户的同名项目可正常创建(如两家公司都有「官网建设」项目);
// 未关联客户的项目按「客户为空」分组校验(与同样未关联客户的同名项目视为重复)。
func crmProjectNameDuplicated(tenantID, name string, customerID *uint64) bool {
name = strings.TrimSpace(name)
if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
return false
}
cond := orm.NewCondition().
And("tenant_id", tenantID).
And("delete_time__isnull", true).
And("project_name", name)
if customerID != nil && *customerID > 0 {
cond = cond.And("customer_id", *customerID)
} else {
cond = cond.AndCond(orm.NewCondition().Or("customer_id__isnull", true).Or("customer_id", 0))
}
cnt, err := models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond).Count()
return err == nil && cnt > 0
}
// List GET /backend/crm/project/list
func (c *BackendCrmProjectController) List() {
claims, err := pipelineClaims(&c.Controller)
@@ -229,13 +250,6 @@ func (c *BackendCrmProjectController) List() {
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 {
@@ -247,32 +261,47 @@ func (c *BackendCrmProjectController) List() {
return
}
} else {
// 列表排列:未完成项目在前、「已完成」项目沉底,两组内部仍按新→旧。
// 先按两组数量把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
unfinished, _ := projectQuery(unfinishedCond()).Count()
unfinishedTotal := int(unfinished)
finishedTotal := int(total) - unfinishedTotal
// 列表排列:按状态分组展示 —— 未开始(1) → 异常(5) → 进行中(2) → 已暂停(4) → 已完成(3),
// 其余状态(NULL / 历史异常值)沉底;组内仍按新→旧。
// 先统计各组数量,把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
groupConds := []*orm.Condition{
orm.NewCondition().And("status", 1), // 未开始
orm.NewCondition().And("status", 5), // 异常
orm.NewCondition().And("status", 2), // 进行中
orm.NewCondition().And("status", 4), // 已暂停
orm.NewCondition().And("status", 3), // 已完成
orm.NewCondition().AndNot("status__in", []int8{1, 2, 3, 4, 5}).Or("status__isnull", true),
}
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 {
cursor := 0
for _, gc := range groupConds {
if cursor >= end {
break
}
// 注意:beego 的 AndCond 会修改接收者,这里用新条件对象包装,避免循环内条件累积
groupCond := orm.NewCondition().AndCond(buildCond()).AndCond(gc)
cnt, _ := projectQuery(groupCond).Count()
groupTotal := int(cnt)
if groupTotal == 0 {
continue
}
groupStart := cursor
groupEnd := cursor + groupTotal
cursor = groupEnd
// 当前页与本组区间的交集
from, to := max(offset, groupStart), min(end, groupEnd)
if to <= from {
continue
}
var part []models.TenantCrmProject
if _, err := projectQuery(groupCond).OrderBy("-id").Offset(from - groupStart).Limit(to - from).All(&part); 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...)
list = append(list, part...)
}
}
}
@@ -394,9 +423,10 @@ func (c *BackendCrmProjectController) Create() {
CreateTime: now,
UpdateTime: now,
}
// 唯一性校验:同租户内不允许重复创建同名项目(重复数据请联系管理员转移跟进)
if crmNameDuplicated(new(models.TenantCrmProject), tenantID, "project_name", proj.ProjectName) {
pipelineErr(&c.Controller, 409, 409, "该项目名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
// 唯一性校验:同一客户(服务主体)下的同名项目不允许重复创建(重复数据请联系管理员转移跟进);
// 客户不同则允许同名创建(不同主体可各自立项,如两家客户都做「官网建设」)
if crmProjectNameDuplicated(tenantID, proj.ProjectName, proj.CustomerID) {
pipelineErr(&c.Controller, 409, 409, "该客户下已存在同名项目(可能由其他同事创建),如需继续跟进请联系管理员转移")
return
}
id, err := models.Orm.Insert(&proj)
+5
View File
@@ -40,6 +40,11 @@ type TenantCrmContract struct {
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
// 回款逾期日期(非表字段,列表 / 详情查询时计算填充):
// 合同下未收齐回款计划里最早的一个「已过计划回款日期」的日期(yyyy-MM-dd);
// 供「逾期」标签与合同结束日期口径一并参考,无回款逾期时为空。
PaybackOverdueDate string `orm:"-" json:"payback_overdue_date"`
}
func (m *TenantCrmContract) TableName() string {