package controllers import ( "encoding/json" "fmt" "io" "math/rand" "strconv" "strings" "time" "server/models" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // BackendCrmContractController CRM 合同管理 // // 进度式创建:前端每完成一步即可保存(Create / Update 均支持), // step 记录创建进度(1=合同信息 2=产品清单),status=1 表示草稿、2 表示已完成。 type BackendCrmContractController struct { beego.Controller } // contractSummary 金额汇总(后端按 products 重算,与前端展示逻辑一致)。 type contractSummary struct { HardwareAmount float64 `json:"hardware_amount"` // 硬件部分金额 SoftwareAmount float64 `json:"software_amount"` // 软件部分金额 OtherAmount float64 `json:"other_amount"` // 其他部分金额(服务/开发/其他) TotalAmount float64 `json:"total_amount"` // 合同总金额 = 硬件 + 软件 + 其他 TotalCost float64 `json:"total_cost"` // 产品总成本 = Σ(数量 × 成本单价) TotalProfit float64 `json:"total_profit"` // 合同总利润 = 总金额 - 总成本 } // contractPayload 创建 / 更新请求体。 type contractPayload struct { ContractNo string `json:"contract_no"` ContractName string `json:"contract_name"` ContractCategory string `json:"contract_category"` OurRole int8 `json:"our_role"` // 我方角色:1甲方/2乙方/3丙方/4丁方 PartyCount int8 `json:"party_count"` ProjectID uint64 `json:"project_id"` ProjectName string `json:"project_name"` OwnerUserID string `json:"owner_user_id"` OwnerUserName string `json:"owner_user_name"` 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"` Status int8 `json:"status"` Remark string `json:"remark"` } // contractResp 列表 / 详情响应:parties / products 解析为 JSON 数组透出,summary 由金额字段组装。 type contractResp struct { models.TenantCrmContract Parties json.RawMessage `json:"parties"` Products json.RawMessage `json:"products"` Summary *contractSummary `json:"summary"` } // List GET /backend/crm/contract/list func (c *BackendCrmContractController) 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")) ourRole := strings.TrimSpace(c.GetString("our_role")) category := strings.TrimSpace(c.GetString("contract_category")) projectType := strings.TrimSpace(c.GetString("project_type")) projectID := strings.TrimSpace(c.GetString("project_id")) status := strings.TrimSpace(c.GetString("status")) tenantID := pipelineTenantID(claims) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) if keyword != "" { // 签约主体名称存储在 parties JSON 中,用 LIKE 一并匹配 kw := orm.NewCondition(). Or("contract_name__contains", keyword). Or("contract_no__contains", keyword). Or("project_name__contains", keyword). Or("owner_user_name__contains", keyword). Or("parties__contains", keyword) cond = cond.AndCond(kw) } if ourRole != "" { cond = cond.And("our_role", ourRole) } if category != "" { cond = cond.And("contract_category", category) } if status != "" { cond = cond.And("status", status) } switch projectType { case "project": // 项目合同 cond = cond.And("project_id__gt", 0) case "headless": // 无头合同 cond = cond.And("project_id__isnull", true) } // 按项目精确筛选(项目详情「合同管理」Tab:一个项目可关联多份合同) if projectID != "" { if pid, err := strconv.ParseUint(projectID, 10, 64); err == nil && pid > 0 { cond = cond.And("project_id", pid) } } qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond) total, _ := qs.Count() var list []models.TenantCrmContract if total > 0 { _, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&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{}{ "list": items, "total": total, "page": page, "pageSize": pageSize, }) } // Stats GET /backend/crm/contract/stats // 全租户合同统计(排除已作废),供列表页顶部汇总卡片使用。 // 支持按周期筛选:period=month/quarter/year(当期)或 custom(start_date~end_date), // 按合同有效期 [生效日期, 结束日期] 与所选周期重叠统计。 func (c *BackendCrmContractController) Stats() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } tenantID := pipelineTenantID(claims) period := strings.TrimSpace(c.GetString("period")) start := strings.TrimSpace(c.GetString("start_date")) end := strings.TrimSpace(c.GetString("end_date")) if period != "" && period != "custom" { start, end = periodRange(period) } table := new(models.TenantCrmContract).TableName() where := "tenant_id = ? AND delete_time IS NULL AND status <> 3" args := []interface{}{tenantID} // 按合同有效期 [生效日期, 结束日期] 与所选周期 [start, end] 重叠来统计: // 合同生效日 <= 周期末 且 合同结束日 >= 周期初;日期为空视为不限制对应边界。 if start != "" { where += " AND (expire_date IS NULL OR expire_date >= ?)" args = append(args, start) } if end != "" { where += " AND (effective_date IS NULL OR effective_date <= ?)" args = append(args, end) } raw := "SELECT COUNT(*) AS total, IFNULL(SUM(total_amount),0) AS total_amount, " + "IFNULL(SUM(total_cost),0) AS total_cost, IFNULL(SUM(total_profit),0) AS total_profit " + "FROM " + table + " WHERE " + where var rows []orm.Params if _, err := models.Orm.Raw(raw, args...).Values(&rows); err != nil || len(rows) == 0 { pipelineOk(&c.Controller, map[string]interface{}{ "total": 0, "total_amount": 0, "total_cost": 0, "total_profit": 0, }) return } r := rows[0] pipelineOk(&c.Controller, map[string]interface{}{ "total": toInt64(r["total"]), "total_amount": toFloat64(r["total_amount"]), "total_cost": toFloat64(r["total_cost"]), "total_profit": toFloat64(r["total_profit"]), }) } // periodRange 根据 period 返回 [start, end] 签约日期区间(YYYY-MM-DD):month/quarter/year 取当期。 func periodRange(period string) (string, string) { now := time.Now() loc := now.Location() y, m, _ := now.Date() switch period { case "quarter": q := (int(m) - 1) / 3 start := time.Date(y, time.Month(q*3+1), 1, 0, 0, 0, 0, loc) end := start.AddDate(0, 3, 0).Add(-time.Nanosecond) return start.Format("2006-01-02"), end.Format("2006-01-02") case "year": start := time.Date(y, 1, 1, 0, 0, 0, 0, loc) end := start.AddDate(1, 0, 0).Add(-time.Nanosecond) return start.Format("2006-01-02"), end.Format("2006-01-02") default: // month start := time.Date(y, m, 1, 0, 0, 0, 0, loc) end := start.AddDate(0, 1, 0).Add(-time.Nanosecond) return start.Format("2006-01-02"), end.Format("2006-01-02") } } // Detail GET /backend/crm/contract/:id func (c *BackendCrmContractController) 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.TenantCrmContract if err := models.Orm.QueryTable(new(models.TenantCrmContract)). Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)). Filter("delete_time__isnull", true).One(&row); err != nil { pipelineErr(&c.Controller, 404, 404, "合同未找到") return } c.syncContractExceptionStatus(&row) pipelineOk(&c.Controller, buildContractResp(&row)) } // Create POST /backend/crm/contract // 进度式保存入口之一:首次保存(通常为草稿),返回 id 后续走 Update。 func (c *BackendCrmContractController) 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 contractPayload if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } if strings.TrimSpace(p.ContractName) == "" { pipelineErr(&c.Controller, 400, 400, "合同名称不能为空") return } tenantID := pipelineTenantID(claims) now := time.Now() row := models.TenantCrmContract{ TenantID: tenantID, ContractNo: strings.TrimSpace(p.ContractNo), ContractName: strings.TrimSpace(p.ContractName), OurRole: pickInt8(p.OurRole, 2, 4), PartyCount: pickInt8(p.PartyCount, 2, 4), OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)), OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)), 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, CreateUserID: pipelineUID(claims), CreateTime: now, UpdateTime: now, } if strings.TrimSpace(p.ContractCategory) != "" { row.ContractCategory = strings.TrimSpace(p.ContractCategory) } // 编号为空时自动生成(查重) if row.ContractNo == "" { row.ContractNo = genContractNo(tenantID) } // 绑定项目:校验项目归属并取标准项目名称 if p.ProjectID > 0 { projID, projName, ok := c.resolveProject(tenantID, p.ProjectID) if !ok { pipelineErr(&c.Controller, 400, 400, "关联项目不存在") return } row.ProjectID = &projID row.ProjectName = projName } // 参与方 / 产品清单 JSON 落库 + 金额重算 parties, err := normalizeContractJSON(p.Parties) if err != nil { pipelineErr(&c.Controller, 400, 400, "参与方数据格式错误") return } row.Parties = parties products, err := normalizeContractJSON(p.Products) if err != nil { pipelineErr(&c.Controller, 400, 400, "产品清单数据格式错误") return } row.Products = products // 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行) projID := uint64(0) if row.ProjectID != nil { projID = *row.ProjectID } if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), projID, row.ProjectName, json.RawMessage(products)); serr == nil { row.Products = string(synced) } applyContractAmounts(&row) if _, err := models.Orm.Insert(&row); err != nil { pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) return } crmWriteLog(tenantID, 3, row.ID, "create", "创建合同:"+row.ContractName, claims) pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID}) } // Update PUT /backend/crm/contract/:id // 进度式保存入口之一:每一步保存都走这里,全量覆盖业务字段。 func (c *BackendCrmContractController) 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 contractPayload if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } tenantID := pipelineTenantID(claims) var row models.TenantCrmContract if err := models.Orm.QueryTable(new(models.TenantCrmContract)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&row); err != nil { pipelineErr(&c.Controller, 404, 404, "合同未找到") return } if strings.TrimSpace(p.ContractName) == "" { pipelineErr(&c.Controller, 400, 400, "合同名称不能为空") return } row.ContractName = strings.TrimSpace(p.ContractName) if no := strings.TrimSpace(p.ContractNo); no != "" { row.ContractNo = no } if strings.TrimSpace(p.ContractCategory) != "" { row.ContractCategory = strings.TrimSpace(p.ContractCategory) } if p.OurRole != 0 { row.OurRole = p.OurRole } if p.PartyCount != 0 { row.PartyCount = p.PartyCount } // 项目绑定支持切换 / 解绑(清空即为无头合同) if p.ProjectID > 0 { projID, projName, ok := c.resolveProject(tenantID, p.ProjectID) if !ok { pipelineErr(&c.Controller, 400, 400, "关联项目不存在") return } row.ProjectID = &projID row.ProjectName = projName } else { row.ProjectID = nil row.ProjectName = "" } if strings.TrimSpace(p.OwnerUserID) != "" { row.OwnerUserID = strings.TrimSpace(p.OwnerUserID) } if strings.TrimSpace(p.OwnerUserName) != "" { row.OwnerUserName = strings.TrimSpace(p.OwnerUserName) } 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 { pipelineErr(&c.Controller, 400, 400, "参与方数据格式错误") return } row.Parties = parties } if p.Products != nil { products, err := normalizeContractJSON(p.Products) if err != nil { pipelineErr(&c.Controller, 400, 400, "产品清单数据格式错误") return } row.Products = products // 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行) projID := uint64(0) if row.ProjectID != nil { projID = *row.ProjectID } if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), projID, row.ProjectName, json.RawMessage(products)); serr == nil { row.Products = string(synced) } } applyContractAmounts(&row) // 状态不随合同编辑变更(避免编辑保存把已流转的状态打回草稿); // 签订/履约/作废等流转统一由「状态流转」接口 ChangeStatus 控制。 if p.Step != 0 { row.Step = p.Step } row.Remark = p.Remark row.UpdateTime = time.Now() if _, err := models.Orm.Update(&row); err != nil { pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error()) return } crmWriteLog(tenantID, 3, row.ID, "update", "更新合同:"+row.ContractName, claims) pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID}) } // Delete DELETE /backend/crm/contract/:id func (c *BackendCrmContractController) 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.TenantCrmContract if err := models.Orm.QueryTable(new(models.TenantCrmContract)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&row); err != nil { pipelineErr(&c.Controller, 404, 404, "合同未找到") return } if !canDeleteCrmRecord(claims, row.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该合同") return } now := time.Now() _, err = models.Orm.QueryTable(new(models.TenantCrmContract)). 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) } // ChangeStatus POST /backend/crm/contract/:id/status // 合同状态流转:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常。 // 向导创建的合同默认为草稿,签订 / 履约等状态在列表中手动流转;各状态间可互切。 func (c *BackendCrmContractController) 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 } // 合法目标状态:2已完成 / 3已作废 / 4履约中 / 5执行异常 if p.Status < 2 || p.Status > 5 { pipelineErr(&c.Controller, 400, 400, "无效的状态值") return } tenantID := pipelineTenantID(claims) var row models.TenantCrmContract if err := models.Orm.QueryTable(new(models.TenantCrmContract)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&row); err != nil { pipelineErr(&c.Controller, 404, 404, "合同未找到") return } if row.Status == p.Status { pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": row.Status}) return } if _, err := models.Orm.QueryTable(new(models.TenantCrmContract)). 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, row.ID, "status", fmt.Sprintf("合同状态流转:%s → %s", contractStatusName(row.Status), contractStatusName(p.Status)), claims) pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": p.Status}) } // contractStatusName 状态文案(用于操作日志)。 func contractStatusName(s int8) string { switch s { case 1: return "草稿" case 2: return "已完成" case 3: return "已作废" case 4: return "履约中" case 5: return "执行异常" default: return "未知" } } // ========================== 内部辅助 ========================== // buildContractResp 组装响应:parties / products 透传 JSON 数组; // 金额字段在返回前按 products 以当前口径重算(单价含税、不再叠加税率),summary 随之组装。 func buildContractResp(row *models.TenantCrmContract) contractResp { parties := json.RawMessage("[]") if strings.TrimSpace(row.Parties) != "" { parties = json.RawMessage(row.Parties) } products := json.RawMessage("[]") if strings.TrimSpace(row.Products) != "" { products = json.RawMessage(row.Products) } // 返回前按「单价含税、不再叠加税率」的口径重算金额, // 保证列表/详情与产品清单一致,并自动修正历史数据按旧口径(叠加税率)存的金额 if strings.TrimSpace(row.Products) != "" && string(products) != "[]" { applyContractAmounts(row) } return contractResp{ TenantCrmContract: *row, Parties: parties, Products: products, Summary: &contractSummary{ HardwareAmount: row.HardwareAmount, SoftwareAmount: row.SoftwareAmount, OtherAmount: row.OtherAmount, TotalAmount: row.TotalAmount, TotalCost: row.TotalCost, TotalProfit: row.TotalProfit, }, } } // resolveProject 校验项目归属当前租户并返回 (id, 标准项目名称)。 func (c *BackendCrmContractController) resolveProject(tenantID string, projectID uint64) (uint64, string, bool) { var proj models.TenantCrmProject if err := models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", projectID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&proj); err != nil { return 0, "", false } return proj.ID, proj.ProjectName, true } // normalizeContractJSON 校验并规范化 JSON 数组(参与方 / 产品清单),返回紧凑 JSON 文本。 func normalizeContractJSON(raw json.RawMessage) (string, error) { if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" { return "[]", nil } var arr []map[string]interface{} if err := json.Unmarshal(raw, &arr); err != nil { return "", err } out, err := json.Marshal(arr) if err != nil { return "", err } return string(out), nil } // applyContractAmounts 按产品清单重算各部分金额(与前端 ProductList 汇总口径一致): // 清单单价为含税价,每行小计=数量×单价(不再叠加税率,税率仅作开票记录); // 硬件部分=Σ硬件小计;软件部分=Σ软件小计;其他部分=Σ服务/开发/其他小计; // 合同总金额=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本为不含税原值); // 合同总利润=总金额-总成本。 // 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他。 // contractTreeSellTotal 软件开发模块树售价合计:子节点汇总,叶子=人天×统一人天单价。 func contractTreeSellTotal(nodes []interface{}, unitPrice float64) float64 { var sum float64 for _, n := range nodes { m, ok := n.(map[string]interface{}) if !ok { continue } if kids, ok := m["children"].([]interface{}); ok && len(kids) > 0 { sum += contractTreeSellTotal(kids, unitPrice) continue } sum += round2(toFloat64(m["man_days"]) * unitPrice) } return round2(sum) } func applyContractAmounts(row *models.TenantCrmContract) { var items []map[string]interface{} if strings.TrimSpace(row.Products) != "" { _ = json.Unmarshal([]byte(row.Products), &items) } var hardware, software, other, cost float64 for _, item := range items { // 软件开发行:开发总成本由模块报价带出(默认 = 人天×统一人天单价,可手工改写),计入产品成本; // 对外金额取销售单价(缺省时回退开发总成本 / 人天汇总,兼容历史数据);不计税率。 // 口径与前端 utils.js 的 lineAmount / lineCost / buildSummary 保持一致。 if fmt.Sprintf("%v", item["line_type"]) == "dev" { qty := toFloat64(item["quantity"]) if qty <= 0 { qty = 1 } total := toFloat64(item["dev_total_price"]) if total <= 0 { if tree, ok := item["dev_tree"].([]interface{}); ok { total = contractTreeSellTotal(tree, toFloat64(item["dev_unit_price"])) } } costPrice := toFloat64(item["cost_price"]) if costPrice <= 0 { costPrice = total } price := toFloat64(item["price"]) if price <= 0 { price = total } software = round2(software + round2(qty*price)) cost = round2(cost + round2(qty*costPrice)) continue } qty := toFloat64(item["quantity"]) price := toFloat64(item["price"]) costPrice := toFloat64(item["cost_price"]) // 清单单价为含税价:小计 = 数量 × 单价,不再叠加税率(税率仅作开票记录,不参与金额计算) amount := round2(qty * price) cost = round2(cost + round2(qty*costPrice)) cat := fmt.Sprintf("%v", item["category"]) switch { case cat == "1" || strings.Contains(cat, "硬件"): hardware = round2(hardware + amount) case cat == "2" || strings.Contains(cat, "软件"): software = round2(software + amount) default: other = round2(other + amount) } } row.HardwareAmount = hardware row.SoftwareAmount = software row.OtherAmount = other row.TotalAmount = round2(hardware + software + other) row.TotalCost = cost row.TotalProfit = round2(row.TotalAmount - cost) } // contractReceivedAmount 汇总合同已回款金额(该合同下所有未删除回款计划的 received_amount 之和)。 func contractReceivedAmount(tenantID string, contractID uint64) float64 { if contractID == 0 { return 0 } var rows []orm.Params sql := "SELECT IFNULL(SUM(received_amount),0) AS received FROM " + new(models.TenantCrmPayback).TableName() + " WHERE tenant_id = ? AND contract_id = ? AND delete_time IS NULL" if _, err := models.Orm.Raw(sql, tenantID, contractID).Values(&rows); err != nil || len(rows) == 0 { return 0 } 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 } now := time.Now() if !row.TechDate.Before(now) { return } 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}) } } // genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。 func genContractNo(tenantID string) string { for i := 0; i < 5; i++ { no := fmt.Sprintf("HT-%s-%04d", time.Now().Format("20060102"), rand.Intn(10000)) count, _ := models.Orm.QueryTable(new(models.TenantCrmContract)). Filter("tenant_id", tenantID).Filter("contract_no", no). Filter("delete_time__isnull", true).Count() if count == 0 { return no } } return fmt.Sprintf("HT-%s-%d", time.Now().Format("20060102"), time.Now().UnixNano()%100000) } // pickInt8 取值约束:v 落在 [min, max] 内返回 v,否则返回 def。 func pickInt8(v, def, max int8) int8 { if v >= 1 && v <= max { return v } return def } // round2 保留两位小数。 func round2(n float64) float64 { return float64(int64((n+1e-9)*100+0.5)) / 100 } // toInt64 orm.Params 值转 int64。 func toInt64(v interface{}) int64 { switch n := v.(type) { case int64: return n case []byte: x, _ := strconv.ParseInt(strings.TrimSpace(string(n)), 10, 64) return x case string: x, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64) return x } return 0 } // toFloat64 orm.Params / JSON 数值转 float64。 func toFloat64(v interface{}) float64 { switch n := v.(type) { case float64: return n case int64: return float64(n) case []byte: x, _ := strconv.ParseFloat(strings.TrimSpace(string(n)), 64) return x case string: x, _ := strconv.ParseFloat(strings.TrimSpace(n), 64) return x } return 0 }