Files
yunzerwebsiteallinone/go/controllers/backend_crm_payback.go
T
2026-09-14 17:30:12 +08:00

1104 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()})
}