package services import ( "encoding/json" "strings" "time" "server/models" ) // ============================== 回款逾期联动 ============================== // 规则:回款计划存在「未收齐且已过计划回款日期」的明细(即逾期)时, // - 对应合同状态置为「异常」(5);已作废(3)合同不标记,已是异常(5)时幂等跳过; // - 合同关联的项目状态联动置为「异常」(5);已是异常(5)时幂等跳过。 // // 已收齐判定与「新建回款」机制一致: // - 回款记录按 item_seq 汇总(当前机制); // - 明细行内 received_amount(历史「进度登记」机制)。 // // 说明:回款补齐后不自动恢复状态(无法区分「逾期导致的异常」与人工标注), // 需要恢复时在「状态流转」中手动调整即可。 // paybackSchedulerItem 回款计划明细(items JSON 元素,仅取判定所需字段)。 type paybackSchedulerItem struct { Seq int `json:"seq"` Amount float64 `json:"amount"` PlanDate string `json:"plan_date"` ReceivedAmount float64 `json:"received_amount"` // 历史「进度登记」机制写入的已回款 } // StartPaybackOverdueScheduler 启动回款逾期联动调度器: // 启动 30 秒后执行首次扫描(尽快让历史逾期数据生效),此后每 10 分钟扫描一次。 func StartPaybackOverdueScheduler(stopChan chan struct{}) { go func() { select { case <-time.After(30 * time.Second): syncPaybackOverdueStatuses() case <-stopChan: return } ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: syncPaybackOverdueStatuses() case <-stopChan: return } } }() } // syncPaybackOverdueStatuses 扫描全部未收齐的回款计划并执行逾期联动,返回 (合同更新数, 项目更新数)。 func syncPaybackOverdueStatuses() (int, int) { if models.Orm == nil { return 0, 0 } today := time.Now().Format("2006-01-02") // 未收齐的计划(status=2 已完成跳过;items 为空时无逾期概念) var plans []models.TenantCrmPayback if _, err := models.Orm.QueryTable(new(models.TenantCrmPayback)). Filter("delete_time__isnull", true). Filter("status__ne", 2). All(&plans, "id", "tenant_id", "contract_id", "items"); err != nil { return 0, 0 } contractUpdated, projectUpdated := 0, 0 now := time.Now() handledContracts := make(map[uint64]bool) // 同一合同只处理一次 for _, plan := range plans { if plan.ContractID == nil || *plan.ContractID == 0 { continue } contractID := *plan.ContractID if handledContracts[contractID] { continue } if !paybackPlanOverdueFor(plan, today) { continue } handledContracts[contractID] = true cu, pu := applyContractOverdue(plan.TenantID, contractID, now) contractUpdated += cu projectUpdated += pu } return contractUpdated, projectUpdated } // SyncPaybackOverdueForContract 立即检查指定合同的回款逾期联动 // (供回款计划 / 回款记录变更后调用,让状态即时生效,无需等待定时扫描)。 func SyncPaybackOverdueForContract(tenantID string, contractID uint64) { if models.Orm == nil || contractID == 0 { return } 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", contractID). Filter("delete_time__isnull", true). Filter("status__ne", 2). All(&plans, "id", "tenant_id", "contract_id", "items"); err != nil { return } for _, plan := range plans { if paybackPlanOverdueFor(plan, today) { applyContractOverdue(tenantID, contractID, time.Now()) return } } } // paybackPlanOverdueFor 计划是否逾期:存在「未收齐」明细且计划回款日期已过。 func paybackPlanOverdueFor(plan models.TenantCrmPayback, today string) bool { items := make([]paybackSchedulerItem, 0) if raw := strings.TrimSpace(plan.Items); raw != "" { if err := json.Unmarshal([]byte(raw), &items); err != nil { return false } } if len(items) == 0 { return false } // 回款记录按明细序号汇总(item_seq=0 视为整单登记,不计入任何明细) receivedBySeq := make(map[int]float64) var recs []models.TenantCrmPaybackRecord _, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)). Filter("payback_id", plan.ID). Filter("delete_time__isnull", true). All(&recs, "item_seq", "amount") for _, r := range recs { if r.ItemSeq > 0 { receivedBySeq[r.ItemSeq] += r.Amount } } 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 { return true } } return false } // crmProjectStatusText 项目状态文案(与控制器层 projectStatusName 保持一致,用于操作日志)。 func crmProjectStatusText(s int8) string { switch s { case 1: return "未开始" case 2: return "进行中" case 3: return "已完成" case 4: return "已暂停" case 5: return "异常" } return "未知" } // crmContractStatusText 合同状态文案(与控制器层 contractStatusName 保持一致,用于操作日志)。 func crmContractStatusText(s int8) string { switch s { case 1: return "草稿" case 2: return "已完成" case 3: return "已作废" case 4: return "履约中" case 5: return "异常" } return "未知" } // writeOverdueStatusLog 逾期联动自动改状态时写入 CRM 操作日志。 // 自动置「异常」与人工流转同一口径:日志里必须写明原因,操作者记为「系统自动」,便于事后追溯。 // relatedType=3 与控制器层一致(项目 / 合同的操作日志同属该分类)。 func writeOverdueStatusLog(tenantID string, relatedID uint64, content string, now time.Time) { if models.Orm == nil || relatedID == 0 { return } log := models.TenantCrmOperateLog{ TenantID: tenantID, RelatedType: 3, RelatedID: relatedID, Action: "status", Content: content, OperatorName: "系统自动", CreateTime: now, } _, _ = models.Orm.Insert(&log) } // applyContractOverdue 合同置「异常」(5),并联动项目置「异常」(5)。 // 返回 (合同是否更新, 项目是否更新)。 func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (int, int) { var contract models.TenantCrmContract if err := models.Orm.QueryTable(new(models.TenantCrmContract)). Filter("id", contractID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&contract); err != nil { return 0, 0 } // 已作废合同不参与逾期提醒 if contract.Status == 3 { return 0, 0 } contractUpdated := 0 if contract.Status != 5 { if _, err := models.Orm.QueryTable(new(models.TenantCrmContract)). Filter("id", contractID).Filter("tenant_id", tenantID). Update(map[string]interface{}{"status": int8(5), "update_time": now}); err != nil { return 0, 0 } contractUpdated = 1 // 自动改异常同样写明原因,否则日志里只看到"变异常"却不知为何 writeOverdueStatusLog(tenantID, contractID, "合同状态自动流转:"+crmContractStatusText(contract.Status)+" → 异常;异常原因:回款计划逾期未收齐", now) } // 合同关联项目联动置异常 projectUpdated := 0 if contract.ProjectID != nil && *contract.ProjectID > 0 { var proj models.TenantCrmProject if err := models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", *contract.ProjectID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&proj); err == nil && proj.Status != 5 { if _, err := models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", proj.ID).Filter("tenant_id", tenantID). Update(map[string]interface{}{"status": int8(5), "update_time": now}); err == nil { projectUpdated = 1 writeOverdueStatusLog(tenantID, proj.ID, "项目状态自动流转:"+crmProjectStatusText(proj.Status)+" → 异常;异常原因:关联合同「"+ contract.ContractName+"」回款计划逾期未收齐", now) } } } return contractUpdated, projectUpdated }