优化合同个管理和产品管理相关功能

This commit is contained in:
2026-09-12 23:29:45 +08:00
parent 2b1874943f
commit 4455b95e37
92 changed files with 5367 additions and 3144 deletions
+114 -3
View File
@@ -47,6 +47,7 @@ type contractPayload struct {
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"`
@@ -126,6 +127,7 @@ func (c *BackendCrmContractController) 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{}{
@@ -135,6 +137,8 @@ func (c *BackendCrmContractController) List() {
// 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 {
@@ -142,13 +146,34 @@ func (c *BackendCrmContractController) Stats() {
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 tenant_id = ? AND delete_time IS NULL AND status <> 3"
"FROM " + table + " WHERE " + where
var rows []orm.Params
if _, err := models.Orm.Raw(raw, tenantID).Values(&rows); err != nil || len(rows) == 0 {
pipelineOk(&c.Controller, contractSummary{TotalProfit: 0})
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]
@@ -160,6 +185,28 @@ func (c *BackendCrmContractController) Stats() {
})
}
// 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)
@@ -179,6 +226,7 @@ func (c *BackendCrmContractController) Detail() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
c.syncContractExceptionStatus(&row)
pipelineOk(&c.Controller, buildContractResp(&row))
}
@@ -214,6 +262,7 @@ func (c *BackendCrmContractController) Create() {
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,
@@ -332,6 +381,7 @@ func (c *BackendCrmContractController) Update() {
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 {
@@ -532,6 +582,23 @@ func normalizeContractJSON(raw json.RawMessage) (string, error) {
// 合同总金额(含税)=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本通常不含税,原值汇总);
// 合同总利润=总金额-总成本。
// 归类口径:分类代码 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) != "" {
@@ -539,6 +606,14 @@ func applyContractAmounts(row *models.TenantCrmContract) {
}
var hardware, software, other, cost float64
for _, item := range items {
// 软件开发行:金额来自模块树汇总(统一人天单价×人天,不计税率/成本,与前端 buildSummary 一致)
if fmt.Sprintf("%v", item["line_type"]) == "dev" {
if tree, ok := item["dev_tree"].([]interface{}); ok {
unitPrice := toFloat64(item["dev_unit_price"])
software = round2(software + contractTreeSellTotal(tree, unitPrice))
}
continue
}
qty := toFloat64(item["quantity"])
price := toFloat64(item["price"])
costPrice := toFloat64(item["cost_price"])
@@ -563,6 +638,42 @@ func applyContractAmounts(row *models.TenantCrmContract) {
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++ {
+387
View File
@@ -0,0 +1,387 @@
package controllers
import (
"fmt"
"strings"
"time"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// BackendCrmDashboardController CRM 数据仪表盘:按时间维度聚合真实业务数据。
type BackendCrmDashboardController struct {
beego.Controller
}
/* ------------------------------ 时间维度 ------------------------------ */
// crmPeriod 返回当前周期与上一个周期的起止时间(含当天)。
func crmPeriod(r string) (curStart, curEnd, prevStart, prevEnd time.Time) {
now := time.Now()
y, m, d := now.Date()
loc := now.Location()
switch r {
case "day":
curStart = time.Date(y, m, d, 0, 0, 0, 0, loc)
curEnd = curStart.Add(24*time.Hour - time.Second)
prevStart = curStart.AddDate(0, 0, -1)
prevEnd = curEnd.AddDate(0, 0, -1)
case "week":
wd := int(now.Weekday()) // 0 周日 ~ 6 周六
if wd == 0 {
wd = 7
}
monday := time.Date(y, m, d, 0, 0, 0, 0, loc).AddDate(0, 0, -(wd - 1))
curStart = monday
curEnd = monday.AddDate(0, 0, 7).Add(-time.Second)
prevStart = monday.AddDate(0, 0, -7)
prevEnd = curStart.Add(-time.Second)
case "month":
curStart = time.Date(y, m, 1, 0, 0, 0, 0, loc)
curEnd = time.Date(y, m+1, 1, 0, 0, 0, 0, loc).Add(-time.Second)
prevStart = time.Date(y, m-1, 1, 0, 0, 0, 0, loc)
prevEnd = curStart.Add(-time.Second)
case "quarter":
q := (int(m) - 1) / 3
qs := time.Date(y, time.Month(q*3+1), 1, 0, 0, 0, 0, loc)
curStart = qs
curEnd = qs.AddDate(0, 3, 0).Add(-time.Second)
prevStart = qs.AddDate(0, -3, 0)
prevEnd = curStart.Add(-time.Second)
default: // year
curStart = time.Date(y, 1, 1, 0, 0, 0, 0, loc)
curEnd = time.Date(y+1, 1, 1, 0, 0, 0, 0, loc).Add(-time.Second)
prevStart = time.Date(y-1, 1, 1, 0, 0, 0, 0, loc)
prevEnd = curStart.Add(-time.Second)
}
return
}
// crmBucketExpr 按时间维度返回分桶表达式(0..n-1 的整数桶序号)。
func crmBucketExpr(r, dateCol string) string {
switch r {
case "day":
return fmt.Sprintf("FLOOR(HOUR(%s)/2)", dateCol) // 每 2 小时一桶,0~11
case "week":
return fmt.Sprintf("WEEKDAY(%s)", dateCol) // 周一=0 ~ 周日=6
case "month":
return fmt.Sprintf("DAY(%s)-1", dateCol) // 1日=0
case "quarter":
return fmt.Sprintf("(MONTH(%s)-1) %% 3", dateCol) // 季度内月序号 0~2
default:
return fmt.Sprintf("MONTH(%s)-1", dateCol) // 年内月序号 0~11
}
}
// crmAxis 返回与分桶对齐的 X 轴文案。
func crmAxis(r string, start time.Time) []string {
loc := start.Location()
switch r {
case "day":
ax := make([]string, 12)
for i := 0; i < 12; i++ {
ax[i] = fmt.Sprintf("%02d:00", i*2)
}
return ax
case "week":
return []string{"周一", "周二", "周三", "周四", "周五", "周六", "周日"}
case "month":
last := time.Date(start.Year(), start.Month()+1, 0, 0, 0, 0, 0, loc).Day()
ax := make([]string, last)
for i := 0; i < last; i++ {
ax[i] = fmt.Sprintf("%d日", i+1)
}
return ax
case "quarter":
q := (int(start.Month()) - 1) / 3
ax := make([]string, 3)
for i := 0; i < 3; i++ {
ax[i] = fmt.Sprintf("%d月", q*3+i+1)
}
return ax
default:
ax := make([]string, 12)
for i := 0; i < 12; i++ {
ax[i] = fmt.Sprintf("%d月", i+1)
}
return ax
}
}
/* ------------------------------ 聚合查询 ------------------------------ */
func crmCountWhere(tenantID, table, dateCol, extra string, start, end time.Time) float64 {
var n int64
sql := fmt.Sprintf(
"SELECT COUNT(*) FROM %s WHERE tenant_id=? AND delete_time IS NULL AND %s BETWEEN ? AND ? %s",
table, dateCol, extra,
)
if err := models.Orm.Raw(sql, tenantID, start, end).QueryRow(&n); err != nil {
return 0
}
return float64(n)
}
func crmSumWhere(tenantID, table, dateCol, sumCol, extra string, start, end time.Time) float64 {
var s float64
sql := fmt.Sprintf(
"SELECT COALESCE(SUM(%s),0) FROM %s WHERE tenant_id=? AND delete_time IS NULL AND %s BETWEEN ? AND ? %s",
sumCol, table, dateCol, extra,
)
if err := models.Orm.Raw(sql, tenantID, start, end).QueryRow(&s); err != nil {
return 0
}
return s
}
// crmSeriesWhere 按时间分桶返回序列(长度 = bucketCount,缺失桶补 0)。
func crmSeriesWhere(tenantID, table, dateCol, metric, sumCol, extra, bucketExpr string, bucketCount int, start, end time.Time) []float64 {
out := make([]float64, bucketCount)
var rows []struct {
B int `orm:"column(b)"`
V float64 `orm:"column(v)"`
}
var sel string
if metric == "sum" {
sel = fmt.Sprintf("SELECT %s AS b, COALESCE(SUM(%s),0) AS v", bucketExpr, sumCol)
} else {
sel = fmt.Sprintf("SELECT %s AS b, COUNT(*) AS v", bucketExpr)
}
sql := fmt.Sprintf(
"%s FROM %s WHERE tenant_id=? AND delete_time IS NULL AND %s BETWEEN ? AND ? %s GROUP BY b",
sel, table, dateCol, extra,
)
if _, err := models.Orm.Raw(sql, tenantID, start, end).QueryRows(&rows); err != nil {
return out
}
for _, row := range rows {
if row.B >= 0 && row.B < bucketCount {
out[row.B] = row.V
}
}
return out
}
func crmRound1(v float64) float64 {
return float64(int64(v*10+0.5)) / 10
}
/* ------------------------------ 实体定义 ------------------------------ */
type dashEntity struct {
table string
dateCol string
extra string
}
// crmCountEntities 计数的实体(新增客户/联系人/项目/合同/线索/商机/回访)。
func crmCountEntities() map[string]dashEntity {
return map[string]dashEntity{
"newCustomer": {"yz_backend_erp_customer", "create_time", " AND in_pool=0 AND is_draft=0"},
"newContact": {"yz_backend_contact_company", "create_time", " AND company_type='customer'"},
"newProject": {"yz_backend_crm_project", "create_time", ""},
"newContract": {"yz_backend_crm_contract", "create_time", ""},
"newClue": {"yz_backend_crm_clue", "create_time", ""},
"newChance": {"yz_backend_crm_business", "create_time", ""},
"newVisit": {"yz_backend_crm_follow", "follow_time", " AND follow_time IS NOT NULL"},
}
}
// crmSumEntities 求和的实体(各金额)。
func crmSumEntities() map[string]dashEntity {
return map[string]dashEntity{
"projectAmount": {"yz_backend_crm_project", "create_time", ""},
"contractAmount": {"yz_backend_crm_contract", "create_time", ""},
"chanceAmount": {"yz_backend_crm_business", "create_time", ""},
"paymentAmount": {"yz_backend_crm_payback", "create_time", ""},
}
}
func crmAmountCol(key string) string {
switch key {
case "projectAmount":
return "amount"
case "contractAmount":
return "total_amount"
case "chanceAmount":
return "amount"
case "paymentAmount":
return "received_amount"
}
return "amount"
}
/* ------------------------------ 主接口 ------------------------------ */
// Summary GET /backend/crm/dashboard?range=day|week|month|quarter|year
// 返回仪表盘所需的全部真实统计数据。
func (c *BackendCrmDashboardController) Summary() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
tenantID := pipelineTenantID(claims)
r := strings.TrimSpace(c.GetString("range"))
valid := map[string]bool{"day": true, "week": true, "month": true, "quarter": true, "year": true}
if !valid[r] {
r = "month"
}
curStart, curEnd, prevStart, prevEnd := crmPeriod(r)
axis := crmAxis(r, curStart)
bucketCount := len(axis)
bucketExpr := crmBucketExpr(r, "create_time")
counts := crmCountEntities()
sums := crmSumEntities()
statKeys := []string{
"newCustomer", "newContact", "newProject", "newContract", "newClue",
"newChance", "projectAmount", "contractAmount", "chanceAmount", "paymentAmount", "newVisit",
}
stats := map[string]map[string]float64{}
for _, k := range statKeys {
var cur, prev float64
if e, ok := counts[k]; ok {
cur = crmCountWhere(tenantID, e.table, e.dateCol, e.extra, curStart, curEnd)
prev = crmCountWhere(tenantID, e.table, e.dateCol, e.extra, prevStart, prevEnd)
} else if e, ok := sums[k]; ok {
col := crmAmountCol(k)
cur = crmSumWhere(tenantID, e.table, e.dateCol, col, e.extra, curStart, curEnd)
prev = crmSumWhere(tenantID, e.table, e.dateCol, col, e.extra, prevStart, prevEnd)
}
var trend float64
if prev > 0 {
trend = (cur - prev) / prev * 100
} else if cur > 0 {
trend = 100
}
stats[k] = map[string]float64{"value": crmRound1(cur), "trend": crmRound1(trend)}
}
// 趋势图:新增客户 / 新增商机 按桶分布
customerSeries := crmSeriesWhere(tenantID, counts["newCustomer"].table, counts["newCustomer"].dateCol,
"count", "", counts["newCustomer"].extra, crmBucketExpr(r, counts["newCustomer"].dateCol), bucketCount, curStart, curEnd)
chanceSeries := crmSeriesWhere(tenantID, counts["newChance"].table, counts["newChance"].dateCol,
"count", "", counts["newChance"].extra, crmBucketExpr(r, counts["newChance"].dateCol), bucketCount, curStart, curEnd)
// 金额对比图:合同金额 / 回款金额 按桶分布
contractSeries := crmSeriesWhere(tenantID, sums["contractAmount"].table, sums["contractAmount"].dateCol,
"sum", "total_amount", sums["contractAmount"].extra, bucketExpr, bucketCount, curStart, curEnd)
paymentSeries := crmSeriesWhere(tenantID, sums["paymentAmount"].table, sums["paymentAmount"].dateCol,
"sum", "received_amount", sums["paymentAmount"].extra, crmBucketExpr(r, sums["paymentAmount"].dateCol), bucketCount, curStart, curEnd)
// 销售漏斗:线索 / 商机 / 合同 / 回款(期内各实体新增数)
funnel := []map[string]interface{}{
{"name": "线索", "value": crmCountWhere(tenantID, counts["newClue"].table, counts["newClue"].dateCol, counts["newClue"].extra, curStart, curEnd)},
{"name": "商机", "value": crmCountWhere(tenantID, counts["newChance"].table, counts["newChance"].dateCol, counts["newChance"].extra, curStart, curEnd)},
{"name": "合同", "value": crmCountWhere(tenantID, counts["newContract"].table, counts["newContract"].dateCol, counts["newContract"].extra, curStart, curEnd)},
{"name": "回款", "value": crmCountWhere(tenantID, sums["paymentAmount"].table, sums["paymentAmount"].dateCol, sums["paymentAmount"].extra, curStart, curEnd)},
}
// 线索来源分布
var srcRows []struct {
Source string `orm:"column(source)"`
Cnt float64 `orm:"column(cnt)"`
}
srcSQL := "SELECT source, COUNT(*) AS cnt FROM yz_backend_crm_clue WHERE tenant_id=? AND delete_time IS NULL AND create_time BETWEEN ? AND ? GROUP BY source ORDER BY cnt DESC"
_, _ = models.Orm.Raw(srcSQL, tenantID, curStart, curEnd).QueryRows(&srcRows)
source := make([]map[string]interface{}, 0, len(srcRows))
for _, row := range srcRows {
name := strings.TrimSpace(row.Source)
if name == "" {
name = "未填写"
}
source = append(source, map[string]interface{}{"name": name, "value": row.Cnt})
}
// 销售业绩排行:按负责人合同金额 Top8,并补齐回款金额
var rankRows []struct {
OwnerUserID string `orm:"column(owner_user_id)"`
OwnerUserName string `orm:"column(owner_user_name)"`
Contract float64 `orm:"column(contract)"`
}
rankSQL := "SELECT owner_user_id, owner_user_name, COALESCE(SUM(total_amount),0) AS contract FROM yz_backend_crm_contract WHERE tenant_id=? AND delete_time IS NULL AND create_time BETWEEN ? AND ? GROUP BY owner_user_id, owner_user_name ORDER BY contract DESC LIMIT 8"
_, _ = models.Orm.Raw(rankSQL, tenantID, curStart, curEnd).QueryRows(&rankRows)
paymentMap := map[string]float64{}
var payRows []struct {
OwnerUserID string `orm:"column(owner_user_id)"`
Payment float64 `orm:"column(payment)"`
}
paySQL := "SELECT owner_user_id, COALESCE(SUM(received_amount),0) AS payment FROM yz_backend_crm_payback WHERE tenant_id=? AND delete_time IS NULL AND create_time BETWEEN ? AND ? GROUP BY owner_user_id"
_, _ = models.Orm.Raw(paySQL, tenantID, curStart, curEnd).QueryRows(&payRows)
for _, row := range payRows {
paymentMap[row.OwnerUserID] = row.Payment
}
rank := make([]map[string]interface{}, 0, len(rankRows))
for _, row := range rankRows {
name := strings.TrimSpace(row.OwnerUserName)
if name == "" {
name = "未知"
}
rank = append(rank, map[string]interface{}{
"name": name,
"dept": "",
"contract": crmRound1(row.Contract),
"payment": crmRound1(paymentMap[row.OwnerUserID]),
})
}
// 最新回访
var visitRows []struct {
RelatedName string `orm:"column(related_name)"`
FollowType string `orm:"column(follow_type)"`
Content string `orm:"column(content)"`
OwnerUserName string `orm:"column(owner_user_name)"`
FollowTime *time.Time `orm:"column(follow_time)"`
}
visitSQL := "SELECT related_name, follow_type, content, owner_user_name, follow_time FROM yz_backend_crm_follow WHERE tenant_id=? AND delete_time IS NULL AND follow_time IS NOT NULL ORDER BY follow_time DESC LIMIT 6"
_, _ = models.Orm.Raw(visitSQL, tenantID).QueryRows(&visitRows)
visits := make([]map[string]interface{}, 0, len(visitRows))
for _, row := range visitRows {
way := strings.TrimSpace(row.FollowType)
if way == "" {
way = "其他"
}
user := strings.TrimSpace(row.OwnerUserName)
if user == "" {
user = "未知"
}
timeStr := ""
if row.FollowTime != nil {
timeStr = row.FollowTime.Format("01-02 15:04")
}
visits = append(visits, map[string]interface{}{
"customer": row.RelatedName,
"way": way,
"content": row.Content,
"user": user,
"time": timeStr,
})
}
pipelineOk(&c.Controller, map[string]interface{}{
"range": r,
"stats": stats,
"trend": map[string]interface{}{
"axis": axis,
"customer": customerSeries,
"chance": chanceSeries,
},
"funnel": funnel,
"amount": map[string]interface{}{
"axis": axis,
"contract": contractSeries,
"payment": paymentSeries,
},
"source": source,
"rank": rank,
"visits": visits,
})
}
+646
View File
@@ -0,0 +1,646 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"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 数组透出。
type paybackResp struct {
models.TenantCrmPayback
Items json.RawMessage `json:"items"`
}
// 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)
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 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
items := make([]paybackResp, 0, len(list))
for i := range list {
items = append(items, buildPaybackResp(&list[i]))
}
// 汇总(当前筛选条件下全部计划的计划总额 / 已回款 / 待回款),用于列表页统计卡片
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)
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)
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 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, _, err := normalizePaybackItems(p.Items)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "计划明细格式错误")
return
}
row.Items = itemsJSON
}
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, "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 !canDeleteCrmRecord(claims, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该回款计划")
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)
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
}
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)
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)
}
+42 -21
View File
@@ -20,16 +20,19 @@ type BackendCrmProductController struct {
}
type crmProductPayload struct {
ProductNo string `json:"product_no"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Unit string `json:"unit"`
Spec string `json:"spec"`
Price float64 `json:"price"`
CostPrice float64 `json:"cost_price"`
TaxRate float64 `json:"tax_rate"`
Status int8 `json:"status"`
Remark string `json:"remark"`
ProductNo string `json:"product_no"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Unit string `json:"unit"`
Spec string `json:"spec"`
Price float64 `json:"price"`
CostPrice float64 `json:"cost_price"`
TaxRate float64 `json:"tax_rate"`
LineType string `json:"line_type"`
DevUnitPrice float64 `json:"dev_unit_price"`
DevTree json.RawMessage `json:"dev_tree"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
// crmProductItem 项目产品清单中的单行(也用于项目生成时写入产品管理)。
@@ -139,6 +142,10 @@ func (c *BackendCrmProductController) Create() {
if status != 0 && status != 1 {
status = 1
}
lineType := strings.TrimSpace(p.LineType)
if lineType != "dev" {
lineType = "product"
}
now := time.Now()
row := models.TenantCrmProduct{
TenantID: pipelineTenantID(claims),
@@ -150,6 +157,9 @@ func (c *BackendCrmProductController) Create() {
Price: p.Price,
CostPrice: p.CostPrice,
TaxRate: p.TaxRate,
LineType: lineType,
DevUnitPrice: p.DevUnitPrice,
DevTree: string(p.DevTree),
Status: status,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
@@ -198,21 +208,28 @@ func (c *BackendCrmProductController) Update() {
if status != 0 && status != 1 {
status = row.Status
}
lineType := strings.TrimSpace(p.LineType)
if lineType != "dev" {
lineType = "product"
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(orm.Params{
"product_no": strings.TrimSpace(p.ProductNo),
"product_name": strings.TrimSpace(p.ProductName),
"category": strings.TrimSpace(p.Category),
"unit": strings.TrimSpace(p.Unit),
"spec": strings.TrimSpace(p.Spec),
"price": p.Price,
"cost_price": p.CostPrice,
"tax_rate": p.TaxRate,
"status": status,
"remark": p.Remark,
"update_time": now,
"product_no": strings.TrimSpace(p.ProductNo),
"product_name": strings.TrimSpace(p.ProductName),
"category": strings.TrimSpace(p.Category),
"unit": strings.TrimSpace(p.Unit),
"spec": strings.TrimSpace(p.Spec),
"price": p.Price,
"cost_price": p.CostPrice,
"tax_rate": p.TaxRate,
"line_type": lineType,
"dev_unit_price": p.DevUnitPrice,
"dev_tree": p.DevTree,
"status": status,
"remark": p.Remark,
"update_time": now,
})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
@@ -371,6 +388,10 @@ func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMe
}
now := time.Now()
for _, it := range items {
// 软件开发行(line_type=dev)不走产品管理建档,仅保留模块树
if fmt.Sprintf("%v", it["line_type"]) == "dev" {
continue
}
name := strings.TrimSpace(fmt.Sprintf("%v", it["name"]))
if name == "" {
continue
+33 -3
View File
@@ -287,9 +287,20 @@ func (c *BackendCrmAttachController) List() {
includeSource := strings.TrimSpace(c.GetString("include_source")) == "1"
tenantID := pipelineTenantID(claims)
page, _ := strconv.Atoi(c.GetString("page"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
offset := (page - 1) * pageSize
var list []models.TenantCrmAttach
total := 0
if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" {
// 链路模式:线索 → 商机 → 项目 累积
// 链路模式:线索 → 商机 → 项目 累积(跨表汇总后内存分页)
rid, _ := strconv.ParseUint(relatedID, 10, 64)
rt, _ := strconv.Atoi(relatedType)
for _, r := range relatedChain(tenantID, int8(rt), rid) {
@@ -303,6 +314,16 @@ func (c *BackendCrmAttachController) List() {
list = append(list, rows...)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID > list[j].ID })
total = len(list)
start := offset
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
list = list[start:end]
} else {
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if relatedType != "" {
@@ -311,9 +332,18 @@ func (c *BackendCrmAttachController) List() {
if relatedID != "" {
cond = cond.And("related_id", relatedID)
}
_, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond).OrderBy("-id").All(&list)
qs := models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond)
if cnt, err := qs.Count(); err == nil {
total = int(cnt)
}
_, _ = qs.OrderBy("-id").Limit(pageSize, offset).All(&list)
}
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
pipelineOk(&c.Controller, map[string]interface{}{
"list": list,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
// Add POST /backend/crm/attach/add