package controllers import ( "encoding/json" "fmt" "io" "strconv" "strings" "time" "server/models" "server/services" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // BackendCrmPaybackController CRM 回款管理 // // 回款计划针对于合同创建: // - plan_type 回款周期:1月度/2季度/3年度/4进度/5自定义; // - 月度/季度/年度:明细为「期数 + 支付金额」; // - 进度:明细为「进度名称 + 进度百分比 + 进度金额」; // - 自定义:明细为「名称 + 计划日期 + 金额」; // - 回款进度:按计划明细逐期登记实收金额/日期。 type BackendCrmPaybackController struct { beego.Controller } // paybackItem 计划明细行(items JSON 元素)。 type paybackItem struct { Seq int `json:"seq"` Name string `json:"name"` Percent float64 `json:"percent"` Amount float64 `json:"amount"` PlanDate string `json:"plan_date"` ReceivedAmount float64 `json:"received_amount"` ReceivedDate string `json:"received_date"` ReceiptURL string `json:"receipt_url"` // 流水回执地址(选填) ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填) Status int8 `json:"status"` // 1未回款/2部分回款/3已回款 } // paybackPayload 创建 / 更新请求体。 type paybackPayload struct { ContractID uint64 `json:"contract_id"` ContractNo string `json:"contract_no"` ContractName string `json:"contract_name"` CustomerID uint64 `json:"customer_id"` CustomerName string `json:"customer_name"` PlanType int8 `json:"plan_type"` PayMethod int8 `json:"pay_method"` RemindDays int `json:"remind_days"` OwnerUserID string `json:"owner_user_id"` OwnerUserName string `json:"owner_user_name"` ContractAmount float64 `json:"contract_amount"` Items json.RawMessage `json:"items"` Remark string `json:"remark"` Status int8 `json:"status"` ReceiptURL string `json:"receipt_url"` // 流水回执地址(选填) ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填) } // paybackResp 列表 / 详情响应:items 解析为 JSON 数组透出; // children 为回款明细(树形子集:该计划下一笔笔实际回款记录)。 type paybackResp struct { models.TenantCrmPayback Items json.RawMessage `json:"items"` Children []models.TenantCrmPaybackRecord `json:"children"` } // paybackProgress 回款进度行(跨计划摊平的计划明细)。 type paybackProgress struct { PaybackID uint64 `json:"payback_id"` ContractID *uint64 `json:"contract_id"` ContractNo string `json:"contract_no"` ContractName string `json:"contract_name"` CustomerName string `json:"customer_name"` OwnerUserName string `json:"owner_user_name"` PlanType int8 `json:"plan_type"` PayMethod int8 `json:"pay_method"` Seq int `json:"seq"` Name string `json:"name"` Percent float64 `json:"percent"` Amount float64 `json:"amount"` PlanDate string `json:"plan_date"` ReceivedAmount float64 `json:"received_amount"` ReceivedDate string `json:"received_date"` Status int8 `json:"status"` } // List GET /backend/crm/payback/list func (c *BackendCrmPaybackController) List() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } keyword := strings.TrimSpace(c.GetString("keyword")) planType := strings.TrimSpace(c.GetString("plan_type")) payMethod := strings.TrimSpace(c.GetString("pay_method")) status := strings.TrimSpace(c.GetString("status")) contractID := strings.TrimSpace(c.GetString("contract_id")) tenantID := pipelineTenantID(claims) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人) cond = crmApplyOwnerScope(cond, claims) if keyword != "" { cond = cond.AndCond(orm.NewCondition(). Or("contract_name__contains", keyword). Or("contract_no__contains", keyword). Or("customer_name__contains", keyword). Or("owner_user_name__contains", keyword)) } if planType != "" { cond = cond.And("plan_type", planType) } if payMethod != "" { cond = cond.And("pay_method", payMethod) } if status != "" { cond = cond.And("status", status) } if contractID != "" { if cid, err := strconv.ParseUint(contractID, 10, 64); err == nil && cid > 0 { cond = cond.And("contract_id", cid) } } qs := models.Orm.QueryTable(new(models.TenantCrmPayback)).SetCond(cond) total, _ := qs.Count() var list []models.TenantCrmPayback if total > 0 { // 排序:进行中(1) 在前、已完成(2) 在后,同状态内按最新创建优先 _, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list) } // 组装树形数据:每个回款计划挂上它的回款记录作为明细子集 planIDs := make([]uint64, 0, len(list)) for i := range list { planIDs = append(planIDs, list[i].ID) } recordsByPlan := make(map[uint64][]models.TenantCrmPaybackRecord, len(list)) if len(planIDs) > 0 { var recs []models.TenantCrmPaybackRecord _, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)). Filter("tenant_id", tenantID). Filter("payback_id__in", planIDs). Filter("delete_time__isnull", true). OrderBy("-received_date", "-id").All(&recs) for _, r := range recs { recordsByPlan[r.PaybackID] = append(recordsByPlan[r.PaybackID], r) } } items := make([]paybackResp, 0, len(list)) for i := range list { resp := buildPaybackResp(&list[i]) if recs := recordsByPlan[list[i].ID]; len(recs) > 0 { resp.Children = recs } else { resp.Children = []models.TenantCrmPaybackRecord{} } items = append(items, resp) } // 汇总(当前筛选条件下全部计划的计划总额 / 已回款 / 待回款),用于列表页统计卡片 var all []models.TenantCrmPayback _, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).SetCond(cond).All(&all) var summaryTotal, summaryReceived float64 for i := range all { summaryTotal = round2(summaryTotal + all[i].TotalAmount) summaryReceived = round2(summaryReceived + all[i].ReceivedAmount) } pipelineOk(&c.Controller, map[string]interface{}{ "list": items, "total": total, "page": page, "pageSize": pageSize, "summary": map[string]interface{}{ "total_amount": summaryTotal, "received_amount": summaryReceived, "pending_amount": round2(summaryTotal - summaryReceived), }, }) } // Detail GET /backend/crm/payback/:id func (c *BackendCrmPaybackController) Detail() { 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 } var row models.TenantCrmPayback if err := models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)). Filter("delete_time__isnull", true).One(&row); err != nil { pipelineErr(&c.Controller, 404, 404, "回款计划未找到") return } pipelineOk(&c.Controller, buildPaybackResp(&row)) } // Create POST /backend/crm/payback func (c *BackendCrmPaybackController) Create() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } raw, _ := io.ReadAll(c.Ctx.Request.Body) var p paybackPayload if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } if p.ContractID == 0 { pipelineErr(&c.Controller, 400, 400, "请先选择关联合同") 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 { pipelineErr(&c.Controller, 400, 400, "计划明细格式错误") return } if totalAmount <= 0 { pipelineErr(&c.Controller, 400, 400, "计划回款总额不能为 0") return } now := time.Now() row := models.TenantCrmPayback{ TenantID: tenantID, ContractNo: strings.TrimSpace(p.ContractNo), ContractName: strings.TrimSpace(p.ContractName), CustomerName: strings.TrimSpace(p.CustomerName), PlanType: pickInt8(p.PlanType, 1, 5), PayMethod: pickInt8(p.PayMethod, 1, 7), RemindDays: p.RemindDays, OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)), OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)), ContractAmount: round2(p.ContractAmount), TotalAmount: totalAmount, Status: pickInt8(p.Status, 1, 2), Items: itemsJSON, Remark: p.Remark, ReceiptURL: strings.TrimSpace(p.ReceiptURL), ReceiptName: strings.TrimSpace(p.ReceiptName), CreateUserID: pipelineUID(claims), CreateTime: now, UpdateTime: now, } c.fillFromContract(tenantID, p.ContractID, &row) applyPaybackReceived(&row) if _, err := models.Orm.Insert(&row); err != nil { pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) return } crmWriteLog(tenantID, 4, row.ID, "create", "创建回款计划:"+row.ContractName, claims) // 回款逾期联动:新建的计划可能已含逾期节点,合同 / 项目状态即时生效 if row.ContractID != nil { services.SyncPaybackOverdueForContract(tenantID, *row.ContractID) } pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID}) } // Update PUT /backend/crm/payback/:id func (c *BackendCrmPaybackController) Update() { 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 paybackPayload if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } tenantID := pipelineTenantID(claims) var row models.TenantCrmPayback if err := models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&row); err != nil { pipelineErr(&c.Controller, 404, 404, "回款计划未找到") return } // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据 if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据") return } // 已完成(回款已收齐)的回款计划不允许编辑,保证已收齐数据稳定 if row.Status == 2 { pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不允许编辑") return } if p.ContractID > 0 { row.ContractID = &p.ContractID row.ContractNo = strings.TrimSpace(p.ContractNo) row.ContractName = strings.TrimSpace(p.ContractName) row.CustomerName = strings.TrimSpace(p.CustomerName) c.fillFromContract(tenantID, p.ContractID, &row) } if p.PlanType != 0 { row.PlanType = pickInt8(p.PlanType, 1, 5) } if p.PayMethod != 0 { row.PayMethod = pickInt8(p.PayMethod, 1, 7) } row.RemindDays = p.RemindDays if strings.TrimSpace(p.OwnerUserID) != "" { row.OwnerUserID = strings.TrimSpace(p.OwnerUserID) } if strings.TrimSpace(p.OwnerUserName) != "" { row.OwnerUserName = strings.TrimSpace(p.OwnerUserName) } if p.ContractAmount > 0 { row.ContractAmount = round2(p.ContractAmount) } row.Remark = p.Remark row.ReceiptURL = strings.TrimSpace(p.ReceiptURL) row.ReceiptName = strings.TrimSpace(p.ReceiptName) if p.Status != 0 { row.Status = pickInt8(p.Status, 1, 2) } if p.Items != nil { itemsJSON, total, err := normalizePaybackItems(p.Items) if err != nil { pipelineErr(&c.Controller, 400, 400, "计划明细格式错误") return } row.Items = itemsJSON row.TotalAmount = total // 计划总额随明细重算 } row.UpdateTime = time.Now() if _, err := models.Orm.Update(&row); err != nil { pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error()) return } // 已回款金额一律以「回款记录」为准重新汇总: // 明细里不承载实收数据,若此处按明细重算,编辑计划(如调整回款日期)会把已回款冲成 0。 refreshPlanReceived(tenantID, row.ID) // 回款逾期联动:调整计划日期 / 金额后,合同 / 项目状态即时生效 if row.ContractID != nil { services.SyncPaybackOverdueForContract(tenantID, *row.ContractID) } crmWriteLog(tenantID, 4, row.ID, "update", "更新回款计划:"+row.ContractName, claims) pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID}) } // Delete DELETE /backend/crm/payback/:id func (c *BackendCrmPaybackController) Delete() { 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 } tenantID := pipelineTenantID(claims) var row models.TenantCrmPayback if err := models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&row); err != nil { 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 } // 已完成(回款已收齐)的回款计划不允许删除,避免破坏已收齐数据 if row.Status == 2 { pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不允许删除") return } now := time.Now() _, err = models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("id", id).Filter("tenant_id", tenantID). Update(map[string]interface{}{"delete_time": now, "update_time": now}) if err != nil { pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error()) return } pipelineOk(&c.Controller, nil) } // ProgressList GET /backend/crm/payback/progress/list // 回款进度:把全部回款计划的分期明细摊平返回,并汇总计划总额 / 已回款总额。 func (c *BackendCrmPaybackController) ProgressList() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } tenantID := pipelineTenantID(claims) keyword := strings.TrimSpace(c.GetString("keyword")) status := strings.TrimSpace(c.GetString("status")) contractID := strings.TrimSpace(c.GetString("contract_id")) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人) cond = crmApplyOwnerScope(cond, claims) if keyword != "" { cond = cond.AndCond(orm.NewCondition(). Or("contract_name__contains", keyword). Or("customer_name__contains", keyword). Or("owner_user_name__contains", keyword)) } if contractID != "" { if cid, err := strconv.ParseUint(contractID, 10, 64); err == nil && cid > 0 { cond = cond.And("contract_id", cid) } } var list []models.TenantCrmPayback _, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).SetCond(cond).OrderBy("-id").All(&list) rows := make([]paybackProgress, 0) var totalPlanned, totalReceived float64 for i := range list { p := list[i] for _, it := range parsePaybackItems(p.Items) { st := it.Status if st == 0 { st = itemStatus(it.ReceivedAmount, it.Amount) } if status != "" && fmt.Sprintf("%d", st) != status { continue } totalPlanned = round2(totalPlanned + it.Amount) totalReceived = round2(totalReceived + it.ReceivedAmount) rows = append(rows, paybackProgress{ PaybackID: p.ID, ContractID: p.ContractID, ContractNo: p.ContractNo, ContractName: p.ContractName, CustomerName: p.CustomerName, OwnerUserName: p.OwnerUserName, PlanType: p.PlanType, PayMethod: p.PayMethod, Seq: it.Seq, Name: it.Name, Percent: it.Percent, Amount: it.Amount, PlanDate: it.PlanDate, ReceivedAmount: it.ReceivedAmount, ReceivedDate: it.ReceivedDate, Status: st, }) } } pipelineOk(&c.Controller, map[string]interface{}{ "list": rows, "total": len(rows), "total_planned": totalPlanned, "total_received": totalReceived, }) } // Receive POST /backend/crm/payback/:id/receive // 回款进度登记:按明细序号写入实收金额 / 回款日期,并重算计划汇总。 func (c *BackendCrmPaybackController) Receive() { 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 { Seq int `json:"seq"` ReceivedAmount float64 `json:"received_amount"` ReceivedDate string `json:"received_date"` ReceiptURL string `json:"receipt_url"` // 流水回执地址(选填) ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填) } if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } tenantID := pipelineTenantID(claims) var row models.TenantCrmPayback if err := models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&row); err != nil { pipelineErr(&c.Controller, 404, 404, "回款计划未找到") return } // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据 if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据") return } items := parsePaybackItems(row.Items) matched := false for i := range items { if items[i].Seq != p.Seq { continue } items[i].ReceivedAmount = round2(p.ReceivedAmount) items[i].ReceivedDate = strings.TrimSpace(p.ReceivedDate) items[i].ReceiptURL = strings.TrimSpace(p.ReceiptURL) items[i].ReceiptName = strings.TrimSpace(p.ReceiptName) items[i].Status = itemStatus(items[i].ReceivedAmount, items[i].Amount) matched = true break } if !matched { pipelineErr(&c.Controller, 404, 404, "计划明细不存在") return } out, err := json.Marshal(items) if err != nil { pipelineErr(&c.Controller, 500, 500, "保存失败") return } row.Items = string(out) applyPaybackReceived(&row) row.UpdateTime = time.Now() if _, err := models.Orm.Update(&row); err != nil { pipelineErr(&c.Controller, 500, 500, "保存失败: "+err.Error()) return } crmWriteLog(tenantID, 4, row.ID, "receive", fmt.Sprintf("回款登记:%s 第 %d 期 %s 元", row.ContractName, p.Seq, formatAmount(p.ReceivedAmount)), claims) // 回款逾期联动:登记金额变化后,合同 / 项目状态即时生效 if row.ContractID != nil { services.SyncPaybackOverdueForContract(tenantID, *row.ContractID) } pipelineOk(&c.Controller, buildPaybackResp(&row)) } // ========================== 内部辅助 ========================== // buildPaybackResp 组装响应:items 透传 JSON 数组。 func buildPaybackResp(row *models.TenantCrmPayback) paybackResp { items := json.RawMessage("[]") if strings.TrimSpace(row.Items) != "" { items = json.RawMessage(row.Items) } return paybackResp{TenantCrmPayback: *row, Items: items} } // parsePaybackItems 解析明细 JSON(容错:空 / 非法时返回空数组)。 func parsePaybackItems(raw string) []paybackItem { items := make([]paybackItem, 0) if strings.TrimSpace(raw) == "" { return items } _ = json.Unmarshal([]byte(raw), &items) return items } // normalizePaybackItems 校验并规范化明细 JSON:补齐序号、金额取两位小数,返回紧凑 JSON 与计划总额。 func normalizePaybackItems(raw json.RawMessage) (string, float64, error) { if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" { return "[]", 0, nil } var arr []map[string]interface{} if err := json.Unmarshal(raw, &arr); err != nil { return "", 0, err } items := make([]paybackItem, 0, len(arr)) var total float64 for i, m := range arr { it := paybackItem{ Seq: i + 1, Name: strings.TrimSpace(strVal(m["name"])), Percent: round2(toFloat64(m["percent"])), Amount: round2(toFloat64(m["amount"])), PlanDate: strings.TrimSpace(strVal(m["plan_date"])), ReceivedAmount: round2(toFloat64(m["received_amount"])), ReceivedDate: strings.TrimSpace(strVal(m["received_date"])), } if v, ok := m["receipt_url"]; ok && v != nil { it.ReceiptURL = strings.TrimSpace(fmt.Sprintf("%v", v)) } if v, ok := m["receipt_name"]; ok && v != nil { it.ReceiptName = strings.TrimSpace(fmt.Sprintf("%v", v)) } if v, ok := m["seq"]; ok { if s := int(toFloat64(v)); s > 0 { it.Seq = s } } it.Status = itemStatus(it.ReceivedAmount, it.Amount) total = round2(total + it.Amount) items = append(items, it) } out, err := json.Marshal(items) if err != nil { return "", 0, err } return string(out), total, nil } // itemStatus 由实收金额推导明细状态:0/空=未回款 1、部分回款 2、已回款 3。 func itemStatus(received, amount float64) int8 { if amount > 0 && received >= amount { return 3 } if received > 0 { return 2 } return 1 } // strVal 安全取字符串:nil / 非字符串时返回空串,避免写出 ""。 func strVal(v interface{}) string { if v == nil { return "" } if s, ok := v.(string); ok { return s } return fmt.Sprintf("%v", v) } // applyPaybackReceived 按明细重算计划总额、已回款金额与状态。 func applyPaybackReceived(row *models.TenantCrmPayback) { items := parsePaybackItems(row.Items) var total, received float64 allDone := len(items) > 0 for i := range items { if items[i].Status == 0 { items[i].Status = itemStatus(items[i].ReceivedAmount, items[i].Amount) } if items[i].Status != 3 { allDone = false } total = round2(total + items[i].Amount) received = round2(received + items[i].ReceivedAmount) } row.TotalAmount = total row.ReceivedAmount = received if out, err := json.Marshal(items); err == nil { row.Items = string(out) } // 状态:1进行中 / 2已完成(全部明细回款完成) if allDone { row.Status = 2 } else if row.Status == 0 { row.Status = 1 } } // fillFromContract 由关联合同补齐合同编号 / 名称 / 客户 / 合同金额(仅在字段为空时填充)。 func (c *BackendCrmPaybackController) fillFromContract(tenantID string, contractID uint64, row *models.TenantCrmPayback) { if contractID == 0 { return } var ct models.TenantCrmContract if err := models.Orm.QueryTable(new(models.TenantCrmContract)). Filter("id", contractID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&ct); err != nil { return } row.ContractID = &ct.ID if row.ContractNo == "" { row.ContractNo = ct.ContractNo } if row.ContractName == "" { row.ContractName = ct.ContractName } if row.ContractAmount <= 0 { row.ContractAmount = ct.TotalAmount } if row.CustomerName == "" { var parties []map[string]interface{} _ = json.Unmarshal([]byte(ct.Parties), &parties) for _, party := range parties { if fmt.Sprintf("%v", party["ref_type"]) != "1" { continue } row.CustomerName = strings.TrimSpace(strVal(party["ref_name"])) if cid := uint64(toFloat64(party["ref_id"])); cid > 0 { row.CustomerID = &cid } break } } } // formatAmount 金额文案(用于操作日志)。 func formatAmount(n float64) string { return strconv.FormatFloat(round2(n), 'f', 2, 64) } // ========================== 回款记录(回款进度流水) ========================== // // 以「回款计划」为先导条件:每次「新建回款」追加一条记录,构成回款进度列表(一笔笔实际到账); // 计划表的 received_amount / status 由本表金额汇总回写(refreshPlanReceived)。 // RecordList GET /backend/crm/payback/record/list // 回款进度:按合同 / 计划返回一笔笔实际回款记录,并给出计划总额与已回款汇总。 func (c *BackendCrmPaybackController) RecordList() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } tenantID := pipelineTenantID(claims) contractID, _ := c.GetUint64("contract_id") paybackID, _ := c.GetUint64("payback_id") cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人) cond = crmApplyOwnerScope(cond, claims) if contractID > 0 { cond = cond.And("contract_id", contractID) } if paybackID > 0 { cond = cond.And("payback_id", paybackID) } list := make([]models.TenantCrmPaybackRecord, 0) _, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)). SetCond(cond).OrderBy("-received_date", "-id").All(&list) // 汇总:回款计划的计划总额与已回款;带 contract_id 时只统计该合同,否则统计全部 // (已回款由回款记录汇总回写,兼容历史计划) var totalPlanned, totalReceived float64 planCond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) planCond = crmApplyOwnerScope(planCond, claims) if contractID > 0 { planCond = planCond.And("contract_id", contractID) } var plans []models.TenantCrmPayback _, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).SetCond(planCond).All(&plans) for _, p := range plans { totalPlanned = round2(totalPlanned + p.TotalAmount) totalReceived = round2(totalReceived + p.ReceivedAmount) } pipelineOk(&c.Controller, map[string]interface{}{ "list": list, "total": len(list), "total_planned": totalPlanned, "total_received": totalReceived, }) } // CreateRecord POST /backend/crm/payback/record // 新建回款:以回款计划为先导,追加一条实际回款记录,并回写计划的已回款金额与状态。 func (c *BackendCrmPaybackController) CreateRecord() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } raw, _ := io.ReadAll(c.Ctx.Request.Body) var p struct { PaybackID uint64 `json:"payback_id"` ItemSeq int `json:"item_seq"` // 对应计划明细序号(1-based),0=未指定(按整单登记) Amount float64 `json:"amount"` ReceivedDate string `json:"received_date"` PayMethod int8 `json:"pay_method"` ReceiptURL string `json:"receipt_url"` ReceiptName string `json:"receipt_name"` Remark string `json:"remark"` } if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } if p.PaybackID == 0 { pipelineErr(&c.Controller, 400, 400, "请先选择回款计划") return } if round2(p.Amount) <= 0 { pipelineErr(&c.Controller, 400, 400, "请填写本次回款金额") return } tenantID := pipelineTenantID(claims) // 回款计划必须存在且属于当前租户(回款进度以计划为先导条件) var plan models.TenantCrmPayback if err := models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("id", p.PaybackID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&plan); err != nil { 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 } // 进度节点:指定 item_seq 时校验其在计划明细中存在,并留存节点名称快照 itemSeq := 0 itemName := "" if p.ItemSeq > 0 { for _, it := range parsePaybackItems(plan.Items) { if it.Seq == p.ItemSeq { itemSeq = it.Seq itemName = it.Name break } } if itemSeq == 0 { pipelineErr(&c.Controller, 400, 400, "计划明细节点不存在") return } } var contractID *uint64 if plan.ContractID != nil { cid := *plan.ContractID contractID = &cid } now := time.Now() row := models.TenantCrmPaybackRecord{ TenantID: tenantID, ContractID: contractID, PaybackID: plan.ID, ItemSeq: itemSeq, ItemName: itemName, Amount: round2(p.Amount), ReceivedDate: strings.TrimSpace(p.ReceivedDate), PayMethod: payMethod, ReceiptURL: strings.TrimSpace(p.ReceiptURL), ReceiptName: strings.TrimSpace(p.ReceiptName), Remark: strings.TrimSpace(p.Remark), OwnerUserID: plan.OwnerUserID, OwnerUserName: plan.OwnerUserName, CreateUserID: pipelineUID(claims), CreateTime: now, UpdateTime: now, } id, ierr := models.Orm.Insert(&row) if ierr != nil { pipelineErr(&c.Controller, 500, 500, "保存失败: "+ierr.Error()) return } refreshPlanReceived(tenantID, plan.ID) if contractID != nil { crmWriteLog(tenantID, 3, *contractID, "payback", fmt.Sprintf("新建回款:%s 元(%s)", formatAmount(row.Amount), row.ReceivedDate), claims) } pipelineOk(&c.Controller, map[string]interface{}{"id": id}) } // DeleteRecord DELETE /backend/crm/payback/record/:id // 删除一条回款记录(软删除),并回写计划的已回款金额与状态。 func (c *BackendCrmPaybackController) DeleteRecord() { 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 } tenantID := pipelineTenantID(claims) var row models.TenantCrmPaybackRecord if err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&row); err != nil { pipelineErr(&c.Controller, 404, 404, "回款记录不存在") return } // 所属回款计划已完成(回款已收齐)时不允许删除回款记录,避免破坏已完成状态 var plan models.TenantCrmPayback if err := models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("id", row.PaybackID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&plan); err == nil && plan.Status == 2 { pipelineErr(&c.Controller, 400, 400, "所属回款计划已完成(回款已收齐),不允许删除回款记录") return } now := time.Now() if _, err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)). Filter("id", id).Filter("tenant_id", tenantID). Update(orm.Params{"delete_time": now, "update_time": now}); err != nil { pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error()) return } refreshPlanReceived(tenantID, row.PaybackID) // 回款逾期联动:删除回款记录后可能转为逾期,合同 / 项目状态即时生效 if row.ContractID != nil { services.SyncPaybackOverdueForContract(tenantID, *row.ContractID) } pipelineOk(&c.Controller, map[string]interface{}{"id": id}) } // UpdateRecord PUT /backend/crm/payback/record/:id // 补传/更新回款流水回执(仅更新回执文件,不改金额与状态)。 func (c *BackendCrmPaybackController) UpdateRecord() { 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 { ReceiptURL string `json:"receipt_url"` ReceiptName string `json:"receipt_name"` } if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } tenantID := pipelineTenantID(claims) var row models.TenantCrmPaybackRecord if err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&row); err != nil { pipelineErr(&c.Controller, 404, 404, "回款记录不存在") return } now := time.Now() if _, uerr := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)). Filter("id", id).Filter("tenant_id", tenantID). Update(orm.Params{ "receipt_url": strings.TrimSpace(p.ReceiptURL), "receipt_name": strings.TrimSpace(p.ReceiptName), "update_time": now, }); uerr != nil { pipelineErr(&c.Controller, 500, 500, "更新失败: "+uerr.Error()) return } pipelineOk(&c.Controller, map[string]interface{}{ "id": id, "receipt_url": strings.TrimSpace(p.ReceiptURL), "receipt_name": strings.TrimSpace(p.ReceiptName), }) } // 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 { return } var rows []orm.Params sql := "SELECT IFNULL(SUM(amount),0) AS received FROM " + new(models.TenantCrmPaybackRecord).TableName() + " WHERE tenant_id = ? AND payback_id = ? AND delete_time IS NULL" if _, err := models.Orm.Raw(sql, tenantID, paybackID).Values(&rows); err != nil || len(rows) == 0 { return } received := round2(toFloat64(rows[0]["received"])) var plan models.TenantCrmPayback if err := models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("id", paybackID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&plan); err != nil { return } status := int8(1) if plan.TotalAmount > 0 && received+0.005 >= plan.TotalAmount { status = 2 } _, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("id", paybackID).Filter("tenant_id", tenantID). Update(orm.Params{"received_amount": received, "status": status, "update_time": time.Now()}) }