1104 lines
38 KiB
Go
1104 lines
38 KiB
Go
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 / 非字符串时返回空串,避免写出 "<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()})
|
||
}
|