253 lines
8.3 KiB
Go
253 lines
8.3 KiB
Go
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
|
|
}
|