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

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
-18
View File
@@ -1,18 +0,0 @@
-- 创建存储配置表
CREATE TABLE IF NOT EXISTS `yz_system_storage_config` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`storage_type` varchar(20) NOT NULL DEFAULT 'local' COMMENT '存储类型: local-本地存储, qiniu-七牛云',
`qiniu_access_key` varchar(255) DEFAULT NULL COMMENT '七牛云AccessKey',
`qiniu_secret_key` varchar(255) DEFAULT NULL COMMENT '七牛云SecretKey',
`qiniu_bucket` varchar(128) DEFAULT NULL COMMENT '七牛云Bucket名称',
`qiniu_domain` varchar(255) DEFAULT NULL COMMENT '七牛云CDN域名',
`qiniu_region` varchar(50) DEFAULT NULL COMMENT '七牛云存储区域',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统存储配置表';
-- 插入默认配置(本地存储)
INSERT INTO `yz_system_storage_config` (`storage_type`, `create_time`)
VALUES ('local', NOW())
ON DUPLICATE KEY UPDATE `storage_type` = 'local';
@@ -1,3 +0,0 @@
-- 为 AI 聊天消息表增加图片字段(用户上传的图片,base64 dataURL 的 JSON 数组)
ALTER TABLE `yz_backend_ai_chat_message`
ADD COLUMN `images` TEXT NULL COMMENT '用户上传的图片(base64 dataURL 的 JSON 数组)';
@@ -1,6 +0,0 @@
-- 为 AI 聊天消息表增加 token 用量与响应耗时字段
-- 用途:在对话界面展示每条助手回复的响应时间与消耗的 tokens
ALTER TABLE yz_backend_ai_chat_message
ADD COLUMN tokens INT NOT NULL DEFAULT 0 COMMENT '本次响应消耗的 token 总数',
ADD COLUMN duration_ms INT NOT NULL DEFAULT 0 COMMENT 'AI 响应耗时(毫秒)',
ADD COLUMN model VARCHAR(64) NOT NULL DEFAULT '' COMMENT '生成该消息使用的模型';
+63
View File
@@ -0,0 +1,63 @@
-- =============================================================
-- CRM 逾期(overdue)判定与索引
-- 说明:
-- 逾期以「当前日期」实时判定,无需新增存储字段。
-- 前端在列表/详情中展示「逾期 N 天」标签,规则如下:
-- - 项目 : 结束日期(end_date) < 今天 且 状态 != 已完成(3)
-- - 合同 : 到期日期(expire_date) < 今天 且 状态 != 已完成(2) / 已作废(3)
-- - 回款明细: 计划回款日期(plan_date) < 今天 且 明细状态 != 已回款(3)
-- 本脚本仅补充判定所需的日期索引,并给出等价 SQL 供统计/定时任务使用。
-- =============================================================
-- 1) 项目:结束日期索引(逾期判定 + 按结束日期排序)
ALTER TABLE `yz_backend_crm_project`
ADD INDEX `idx_end_date` (`end_date`);
-- 2) 合同:到期日期索引(逾期判定 + 按到期日期排序)
ALTER TABLE `yz_backend_crm_contract`
ADD INDEX `idx_expire_date` (`expire_date`);
-- 3) 回款明细逾期:plan_date 内嵌于 items(JSON),无法建单列索引。
-- 如需按「逾期回款明细」做服务端统计,建议将回款明细拆为独立子表
-- (如 yz_backend_crm_payback_item,含 plan_date DATE 列),再建索引。
-- 当前版本逾期在应用层(前端/接口)实时计算,items 已含 plan_date。
-- =============================================================
-- 等价查询(统计「逾期」记录,供运营/定时任务参考)
-- 注意:DATE(end_date) < CURDATE() 表示「结束日期在今天之前」。
-- =============================================================
-- 逾期项目(结束日期已过且未完成)
SELECT id, project_name, end_date, status
FROM `yz_backend_crm_project`
WHERE `delete_time` IS NULL
AND `end_date` IS NOT NULL
AND DATE(`end_date`) < CURDATE()
AND `status` <> 3; -- 3=已完成
-- 逾期合同(到期日期已过且非已完成/已作废)
SELECT id, contract_name, expire_date, status
FROM `yz_backend_crm_contract`
WHERE `delete_time` IS NULL
AND `expire_date` IS NOT NULL
AND DATE(`expire_date`) < CURDATE()
AND `status` NOT IN (2, 3); -- 2=已完成, 3=已作废
-- 逾期回款明细(MySQL 5.7+ 可用 JSON 函数;低版本建议在应用层计算)
-- 列出「存在任一逾期明细」的回款计划
SELECT p.id, p.contract_name,
j.seq, j.name, j.plan_date, j.status
FROM `yz_backend_crm_payback` p,
JSON_TABLE(
p.items,
'$[*]' COLUMNS (
seq INT PATH '$.seq',
name VARCHAR(100) PATH '$.name',
plan_date DATE PATH '$.plan_date',
status INT PATH '$.status'
)
) j
WHERE p.`delete_time` IS NULL
AND j.plan_date IS NOT NULL
AND DATE(j.plan_date) < CURDATE()
AND j.status <> 3; -- 3=已回款
@@ -0,0 +1,42 @@
-- =============================================================
-- 回款计划表(yz_backend_crm_payback)建表 / 核对脚本
-- =============================================================
-- 说明:
-- 1. 计划明细 items 以 JSON 文本存储,plan_date(计划回款日期)已内置于
-- 各类 plan_type 的明细结构中,无需新增独立列,故无 ALTER TABLE 语句。
-- 2. 各 plan_type 的 items 明细结构:
-- 月度/季度/年度:{"seq":int,"name":"第1期","amount":0,"plan_date":"2026-10-01"}
-- 进度: {"seq":int,"name":"预付款","percent":30,"amount":0,"plan_date":"2026-10-01"}
-- 自定义: {"seq":int,"name":"首付款","plan_date":"2026-10-01","amount":0}
-- 每行另含 received_amount / received_date / status(回款进度登记用)。
-- 3. 执行下方建表脚本可创建或核对表结构(幂等,已存在则跳过)。
-- =============================================================
CREATE TABLE IF NOT EXISTS `yz_backend_crm_payback` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` VARCHAR(64) NOT NULL DEFAULT '',
`contract_id` BIGINT UNSIGNED NULL,
`contract_no` VARCHAR(50) NOT NULL DEFAULT '',
`contract_name` VARCHAR(100) NOT NULL DEFAULT '',
`customer_id` BIGINT UNSIGNED NULL,
`customer_name` VARCHAR(128) NOT NULL DEFAULT '',
`plan_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1月度/2季度/3年度/4进度/5自定义',
`pay_method` TINYINT NOT NULL DEFAULT 1 COMMENT '1对公/2网银/3现金/4支票/5支付宝/6微信/7其他',
`remind_days` INT NOT NULL DEFAULT 0 COMMENT '提前几天提醒',
`owner_user_id` VARCHAR(64) NOT NULL DEFAULT '',
`owner_user_name` VARCHAR(128) NOT NULL DEFAULT '',
`contract_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '合同总金额',
`total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '计划回款总额',
`received_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '已回款金额',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1进行中/2已完成',
`items` TEXT NULL COMMENT '计划明细 JSON 数组(含 plan_date 计划回款日期)',
`remark` TEXT NULL,
`create_user_id` VARCHAR(64) NOT NULL DEFAULT '',
`create_time` DATETIME NOT NULL,
`update_time` DATETIME NOT NULL,
`delete_time` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_yz_backend_crm_payback_tenant` (`tenant_id`),
KEY `idx_yz_backend_crm_payback_contract` (`contract_id`),
KEY `idx_yz_backend_crm_payback_delete` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回款计划表';
@@ -0,0 +1,12 @@
-- =============================================================
-- CRM 回款计划:流水回执(receipt)字段
-- 说明:
-- 回款登记支持上传「流水回执」(银行流水 / 转账凭证等),非必填。
-- 新增两列用于保存回执文件地址与文件名,随回款计划主表一并存取。
-- 文件本身经 /backend/uploadfile 上传到文件存储,此处仅存其可访问地址。
-- =============================================================
-- 回款计划表:流水回执地址 + 文件名(均允许为空)
ALTER TABLE `yz_backend_crm_payback`
ADD COLUMN `receipt_url` VARCHAR(512) NULL COMMENT '流水回执文件地址(选填)' AFTER `remark`,
ADD COLUMN `receipt_name` VARCHAR(255) NULL COMMENT '流水回执文件名(选填)' AFTER `receipt_url`;
@@ -1,15 +0,0 @@
-- 项目表新增「产品清单」字段:yz_backend_crm_project.products
-- 手动执行;可重复执行(列已存在时自动跳过,不再报 1060)。
-- 该字段用于保存项目生成产品时写入产品管理的产品清单JSON。
SET @dbname = DATABASE();
SET @tablename = 'yz_backend_crm_project';
SET @columnname = 'products';
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
'SELECT 1',
'ALTER TABLE yz_backend_crm_project ADD COLUMN products text COMMENT ''项目产品清单JSON(写入产品管理时使用)'''
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
@@ -1,93 +0,0 @@
-- 文件存储分层改造:yz_system_files 新增归属与存储字段
-- 对应计划文档:go/docs/文件存储分层改造计划.md S1
--
-- 幂等脚本:用存储过程逐项判断,已存在的列/索引自动跳过,可反复执行,不会报
-- 1060 Duplicate column / 1061 Duplicate key。
--
-- 背景:md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引,
-- 因此 idx_file_dedup 使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符,等价全值)。
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
DELIMITER $$
CREATE PROCEDURE `yz_alter_system_files_storage`()
BEGIN
DECLARE v_db VARCHAR(64);
SET v_db = DATABASE();
-- 1. 字段:source
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'source'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `source` varchar(16) NOT NULL DEFAULT 'backend'
COMMENT '来源端: backend-租户后台 platform-平台端';
END IF;
-- 2. 字段:scope
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'scope'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT 'tenant'
COMMENT '归属: tenant-租户共享 user-用户个人';
END IF;
-- 3. 字段:storage
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'storage'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT ''
COMMENT '存储类型: local/qiniu(冗余,便于迁移与排查)';
END IF;
-- 4. 字段:object_key
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'object_key'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT ''
COMMENT '存储相对路径(不含域名),用于迁移与精确删除';
END IF;
-- 5. 索引:查重(source + scope + tid + tuid + md5 前缀)
-- 不加 UNIQUE:软删(delete_time)与并发上传下唯一索引会直接报错,去重在代码层完成
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_dedup'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`(32));
END IF;
-- 6. 索引:归属列表过滤
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_owner'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`);
END IF;
END$$
DELIMITER ;
CALL `yz_alter_system_files_storage`();
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
-- 7. 老数据初始化:迁移脚本执行前先打底,避免 source/scope 为空导致查询遗漏
UPDATE `yz_system_files` SET `source` = 'backend' WHERE `source` = '' OR `source` IS NULL;
UPDATE `yz_system_files` SET `scope` = 'tenant' WHERE `scope` = '' OR `scope` IS NULL;
-- 8. 校验(应看到 4 个字段 + 2 个索引)
-- SHOW COLUMNS FROM yz_system_files LIKE 'source';
-- SHOW COLUMNS FROM yz_system_files LIKE 'scope';
-- SHOW COLUMNS FROM yz_system_files LIKE 'storage';
-- SHOW COLUMNS FROM yz_system_files LIKE 'object_key';
-- SHOW INDEX FROM yz_system_files WHERE Key_name IN ('idx_file_dedup','idx_file_owner');
-29
View File
@@ -1,29 +0,0 @@
-- CRM 产品管理表:yz_backend_crm_product
-- 手动执行(可重复执行);产品可由「项目生成」时自动写入,也可在「产品管理」中独立维护。
CREATE TABLE IF NOT EXISTS yz_backend_crm_product (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
product_no varchar(50) NOT NULL DEFAULT '' COMMENT '产品编号',
product_name varchar(100) NOT NULL COMMENT '产品名称',
category varchar(20) NOT NULL DEFAULT '' COMMENT '分类:1硬件/2软件/3服务/4开发/5其他',
unit varchar(20) NOT NULL DEFAULT '' COMMENT '单位',
spec varchar(255) NOT NULL DEFAULT '' COMMENT '规格型号',
price decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '销售单价',
cost_price decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '成本单价',
tax_rate decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '税率(%)',
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用',
project_id bigint(20) DEFAULT NULL COMMENT '来源项目ID(由项目生成时填入)',
project_name varchar(100) NOT NULL DEFAULT '' COMMENT '来源项目名称',
remark text COMMENT '备注',
create_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (id),
KEY idx_tenant_id (tenant_id),
KEY idx_product_name (tenant_id,product_name),
KEY idx_category (tenant_id,category),
KEY idx_project (tenant_id,project_id),
KEY idx_status (tenant_id,status),
KEY idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM产品管理表';
@@ -1,19 +0,0 @@
-- CRM 产品分类表:yz_backend_crm_product_category
-- 手动执行(可重复执行);建表后请在「产品管理-产品分类」中维护数据。
CREATE TABLE IF NOT EXISTS yz_backend_crm_product_category (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
name varchar(100) NOT NULL COMMENT '分类名称',
code varchar(50) NOT NULL DEFAULT '' COMMENT '分类编码(选填)',
sort int(11) NOT NULL DEFAULT '0' COMMENT '排序',
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用',
remark text COMMENT '备注',
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_name (tenant_id,name),
KEY idx_tenant_id (tenant_id),
KEY idx_status (tenant_id,status),
KEY idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM产品分类表';
-45
View File
@@ -1,45 +0,0 @@
-- 投诉建议「产品分类」:区分用户针对哪类产品提建议
-- 请在目标库手动执行(utf8mb4)
CREATE TABLE IF NOT EXISTS `yz_system_complaint_category` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL COMMENT '分类名称,如:官网、租户后台、小程序',
`code` varchar(32) DEFAULT NULL COMMENT '可选编码,便于程序识别',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0禁用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` datetime DEFAULT NULL COMMENT '软删',
PRIMARY KEY (`id`),
KEY `idx_delete_time` (`delete_time`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='投诉建议-产品分类';
CREATE TABLE IF NOT EXISTS `yz_system_platform_complaint` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`category_id` bigint unsigned NOT NULL COMMENT '产品分类ID',
`title` varchar(200) NOT NULL COMMENT '标题',
`content` text NOT NULL COMMENT '建议/投诉内容',
`contact_name` varchar(64) DEFAULT NULL COMMENT '联系人',
`contact_phone` varchar(32) DEFAULT NULL COMMENT '联系电话',
`contact_email` varchar(128) DEFAULT NULL COMMENT '联系邮箱',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1处理中 2已回复 3已关闭',
`reply_content` text COMMENT '平台回复内容',
`reply_time` datetime DEFAULT NULL COMMENT '回复时间',
`tid` bigint unsigned DEFAULT NULL COMMENT '可选:关联租户ID',
`remark` varchar(512) DEFAULT NULL COMMENT '管理员内部备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` datetime DEFAULT NULL COMMENT '软删',
PRIMARY KEY (`id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_status` (`status`),
KEY `idx_delete_time` (`delete_time`),
KEY `idx_tid` (`tid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台端-投诉建议';
-- 可选:示例分类(执行完建表后按需取消注释)
-- INSERT INTO `yz_system_complaint_category` (`name`,`code`,`sort`,`status`) VALUES
-- ('官网','site',0,1),
-- ('租户后台','tenant_admin',10,1),
-- ('小程序','miniapp',20,1);
-129
View File
@@ -1,129 +0,0 @@
-- OA 文档管理(文档库 / 文档图谱)建表脚本
--
-- 说明:正常情况【不需要执行本脚本】。
-- 服务在首次访问 /backend/oa/document/* 接口时,会由
-- controllers/backend_oa_document.go 的 Prepare() 调用
-- models.EnsureOaDocumentTables() 自动建表(CREATE TABLE IF NOT EXISTS,
-- sync.Once 保证单进程只建一次)。
--
-- 本脚本仅用于以下场景手工预建:
-- 1. 生产库账号没有 DDL 权限,自动建表失败;
-- 2. DBA 要求先在预发/生产环境审核建表语句;
-- 3. 需要排查表结构是否符合预期。
--
-- 幂等:全部使用 IF NOT EXISTS,可反复执行,不会覆盖已有数据。
-- 与代码保持一致:models/oa_document.go
-- 1. 文档分类表(树形)
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_category (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '分类归属用户:共享空间共享=0(租户级);私密空间=创建者的 yz_system_tenant_user.uid',
parent_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '父级ID,0为一级分类',
name varchar(128) NOT NULL DEFAULT '' COMMENT '分类名称',
sort int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)',
scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属空间 shared-共享文档空间 personal-私密文档空间(绑定用户级别)',
is_deleted tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (id),
KEY idx_tid_parent (tid, parent_id),
KEY idx_tid_scope_user (tid, scope, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档分类表';
-- 1.1 历史库补齐列(特性上线前已建表时执行,已存在则报错可忽略)
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)';
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属空间 shared-共享 personal-私密';
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '分类归属用户(私密空间=租户用户 uid)';
-- 1.2 分类归属规则(双空间模型,2026-09 起):
-- 文档库按「共享文档 / 私密文档」两个空间组织,每个空间各自一棵分类树:
-- - 共享空间:scope=shared、user_id=0(租户级,全租户共用一套,任何人可建/删,内置分类除外);
-- - 私密空间:scope=personal、user_id=当前用户的 yz_system_tenant_user.uid(绑定到用户级别,各用户独立一套)。
-- 文档可见性(OaDoc.visibility)与所在空间一一对应:共享空间→0(租户公开),私密空间→1(私密,仅创建者与被共享者可见)。
-- 系统内置分类「项目文档」(scope=shared, user_id=0, is_system=1) 属于共享空间,按租户自动补建
-- (models.EnsureOaDocDefaultCategories),一般无需手工插入。
-- 历史脏数据(scope 不在 shared/personal)在 EnsureOaDocumentTables 中自动归为共享空间(user_id=0, scope=shared)。
-- 说明:「未分类」是虚拟分类,文档 category_id = 0 即表示未分类,两个空间各自独立计算,不落库、不可删除。
-- 2. 文档表
-- doc_type: 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频
-- status : 0-草稿 1-已发布 2-已归档
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
category_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '所属分类ID,0为未分类',
title varchar(255) NOT NULL DEFAULT '' COMMENT '文档标题',
file_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '关联附件ID(yz_system_files)',
file_url varchar(512) NOT NULL DEFAULT '' COMMENT '附件访问地址',
file_name varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名',
ext varchar(16) NOT NULL DEFAULT '' COMMENT '扩展名',
size bigint unsigned NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
doc_type tinyint NOT NULL DEFAULT 0 COMMENT '类型 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频',
tags varchar(255) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔',
summary varchar(1000) NOT NULL DEFAULT '' COMMENT '摘要/描述',
status tinyint NOT NULL DEFAULT 0 COMMENT '状态 0-草稿 1-已发布 2-已归档',
version int NOT NULL DEFAULT 1 COMMENT '版本号',
is_star tinyint NOT NULL DEFAULT 0 COMMENT '是否收藏 0-否 1-是',
owner_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '负责人ID',
owner_name varchar(100) NOT NULL DEFAULT '' COMMENT '负责人姓名',
view_count int NOT NULL DEFAULT 0 COMMENT '查看次数',
download_count int NOT NULL DEFAULT 0 COMMENT '下载次数',
creator_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID',
creator_name varchar(100) NOT NULL DEFAULT '' COMMENT '创建人姓名',
visibility tinyint NOT NULL DEFAULT 0 COMMENT '可见性 0-租户公开 1-私密(仅创建者与被共享者)',
is_deleted tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (id),
KEY idx_tid_cate (tid, category_id, is_deleted),
KEY idx_tid_status (tid, status, is_deleted),
KEY idx_tid_title (tid, title)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档表';
-- 3. 文档关联表(文档图谱的连线来源)
-- relation: reference-引用 related-相关 version-版本 belong-从属
-- 说明:代码在建立关联时会同时写入正向与反向两条记录,便于图谱无向检索;
-- uk_doc_pair 保证同一对文档的同一关系不重复。
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_link (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
source_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '源文档ID',
target_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '目标文档ID',
relation varchar(32) NOT NULL DEFAULT 'related' COMMENT '关系 reference-引用 related-相关 version-版本 belong-从属',
remark varchar(255) NOT NULL DEFAULT '' COMMENT '关系说明',
creator_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_doc_pair (tid, source_id, target_id, relation),
KEY idx_tid_source (tid, source_id),
KEY idx_tid_target (tid, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档关联表';
-- 4. 文档共享表(私密文档可见性授权)
-- share_type: 0-用户(target_id=uid) 1-部门(target_id=org_id,含子部门)
-- uk_doc_target 保证同一文档对同一目标不重复授权。
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_share (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
doc_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '文档ID',
share_type tinyint NOT NULL DEFAULT 0 COMMENT '共享类型 0-用户(target_id=uid) 1-部门(target_id=org_id,含子部门)',
target_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '共享目标ID',
creator_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '授权人ID',
create_time datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_doc_target (tid, doc_id, share_type, target_id),
KEY idx_tid_doc (tid, doc_id),
KEY idx_target (tid, share_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档共享表';
-- 5. 校验(应看到 4 张表)
-- SHOW TABLES LIKE 'yz_backend_oa_doc%';
@@ -1,31 +0,0 @@
-- Cursor 激活码管理
-- status: 0 未使用 1 已使用 2 已过期 3 已禁用
-- type: 0 自定义 1 天卡 7 周卡 30 月卡 90 季卡 365 年卡
CREATE TABLE IF NOT EXISTS `yz_platform_cursor_activation_code` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`code` varchar(128) NOT NULL COMMENT '激活码',
`type` int NOT NULL DEFAULT 30 COMMENT '卡密类型:0自定义 1天卡 7周卡 30月卡 90季卡 365年卡',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0未使用 1已使用 2已过期 3已禁用',
`duration_days` int NOT NULL DEFAULT 30 COMMENT '有效天数',
`bind_account` varchar(128) DEFAULT NULL COMMENT '绑定账号',
`bind_device_id` bigint unsigned DEFAULT NULL COMMENT '绑定设备ID,关联 yz_platform_cursor_equipment.id',
`machine_code` varchar(128) DEFAULT NULL COMMENT '绑定设备机器码',
`device_info` varchar(1000) DEFAULT NULL COMMENT '绑定设备信息',
`owner_user_id` bigint unsigned DEFAULT NULL COMMENT '归属用户ID',
`owner_user_name` varchar(128) DEFAULT NULL COMMENT '归属用户名称',
`activated_at` datetime DEFAULT NULL COMMENT '激活时间',
`expired_at` datetime DEFAULT NULL COMMENT '过期时间',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_status_delete` (`status`,`delete_time`),
KEY `idx_type_status` (`type`,`status`),
KEY `idx_bind_account` (`bind_account`),
KEY `idx_bind_device_id` (`bind_device_id`),
KEY `idx_owner_user_id` (`owner_user_id`),
KEY `idx_expired_at` (`expired_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Cursor续杯激活码';
-26
View File
@@ -1,26 +0,0 @@
-- 软件升级产品(客户端拉取版本与下载地址)
-- 安装包建议上传到文件管理,分类使用「appsupgrade」(或任意分类,记录 file_id 即可)
CREATE TABLE IF NOT EXISTS `yz_system_software_upgrade` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(128) NOT NULL COMMENT '软件显示名称',
`code` varchar(64) NOT NULL COMMENT '客户端唯一标识,与 check 接口 code 一致',
`latest_version` varchar(32) NOT NULL DEFAULT '0.0.0' COMMENT '当前发布的最新版本号',
`file_id` bigint unsigned DEFAULT NULL COMMENT '关联 yz_system_files.id,安装包',
`download_url` varchar(512) DEFAULT NULL COMMENT '兼容旧客户端的单安装包地址;为空则用 file_id 对应 src 拼公开 URL',
`download_urls` text DEFAULT NULL COMMENT '多运行环境安装包地址 JSON,如 {"windows":"...","mac":"...","ubuntu":"...","linux":"..."}',
`force_update` tinyint NOT NULL DEFAULT 0 COMMENT '1 建议强制更新',
`release_notes` varchar(2000) DEFAULT NULL COMMENT '更新说明',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1 启用 0 停用',
`sort` int NOT NULL DEFAULT 0,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_status_delete` (`status`,`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='软件升级产品';
-- 已有表升级:
-- ALTER TABLE `yz_system_software_upgrade`
-- ADD COLUMN `download_urls` text DEFAULT NULL COMMENT '多运行环境安装包地址 JSON,如 {"windows":"...","mac":"...","ubuntu":"...","linux":"..."}' AFTER `download_url`;
+69
View File
@@ -84,6 +84,7 @@ func Init(_ string) {
new(TenantCrmEntityContact),
new(TenantCrmOperateLog),
new(TenantCrmContract),
new(TenantCrmPayback),
new(ErpAccountSet),
new(ErpNormalSetting),
new(ErpCompanyContact),
@@ -147,7 +148,49 @@ func Init(_ string) {
EnsureCrmProjectDocColumn()
EnsureCrmContractTable()
EnsureCrmContractOurRoleColumn()
EnsureCrmContractTechDateColumn()
EnsureCrmProjectProductsColumn()
EnsureCrmPaybackTable()
EnsureCrmProductDevColumns()
}
// EnsureCrmPaybackTable 回款计划表建表(CREATE TABLE IF NOT EXISTS,可重复执行;
// 建表失败(如表已存在但结构不一致)时静默忽略,可用 sql/yz_backend_crm_payback.sql 手动修复)。
func EnsureCrmPaybackTable() {
sql := `CREATE TABLE IF NOT EXISTS yz_backend_crm_payback (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
contract_id bigint(20) DEFAULT NULL COMMENT '关联合同ID(yz_backend_crm_contract.id)',
contract_no varchar(50) NOT NULL DEFAULT '' COMMENT '合同编号',
contract_name varchar(100) NOT NULL DEFAULT '' COMMENT '合同名称',
customer_id bigint(20) DEFAULT NULL COMMENT '客户ID',
customer_name varchar(128) NOT NULL DEFAULT '' COMMENT '客户名称',
plan_type tinyint(4) NOT NULL DEFAULT '1' COMMENT '回款周期:1月度/2季度/3年度/4进度/5自定义',
pay_method tinyint(4) NOT NULL DEFAULT '1' COMMENT '回款方式:1对公转账/2网银转账/3现金/4支票/5支付宝/6微信/7其他',
remind_days int(11) NOT NULL DEFAULT '0' COMMENT '提前几天提醒',
owner_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
owner_user_name varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
contract_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总金额',
total_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '计划回款总额',
received_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '已回款金额',
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1进行中/2已完成',
items text COMMENT '计划明细JSON数组',
remark text COMMENT '备注',
create_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (id),
KEY idx_tenant_id (tenant_id),
KEY idx_contract (tenant_id,contract_id),
KEY idx_customer (tenant_id,customer_id),
KEY idx_status (tenant_id,status),
KEY idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM回款计划表'`
if _, err := Orm.Raw(sql).Exec(); err != nil {
// 表已存在或执行失败时忽略(完整表结构见 sql/yz_backend_crm_payback.sql)
return
}
}
// EnsureCrmContractOurRoleColumn 补齐合同表的我方角色字段(存量表已建时新增;
@@ -158,6 +201,14 @@ func EnsureCrmContractOurRoleColumn() {
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmContractTechDateColumn 补齐合同表的技术日期字段(开发/交付完成日期;
// 存量表已建时新增,表不存在或列已存在时忽略错误)。
func EnsureCrmContractTechDateColumn() {
sql := "ALTER TABLE " + new(TenantCrmContract).TableName() +
" ADD COLUMN tech_date date DEFAULT NULL COMMENT '技术日期(开发/交付完成日期;到期且回款未满则自动转执行异常)'"
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmProjectProductsColumn 补齐项目表的产品清单字段(存量表已建时新增;
// 表不存在或列已存在时忽略错误,可用 docs/sql/alter_backend_crm_project_products_column.sql 手动修复)。
func EnsureCrmProjectProductsColumn() {
@@ -221,6 +272,24 @@ func EnsureCrmProjectDocColumn() {
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmProductDevColumns 补齐产品表的软件开发字段(line_type / dev_unit_price / dev_tree);
// 表不存在或列已存在时忽略错误,可用 docs/sql/alter_backend_crm_product_dev_columns.sql 手动修复。
func EnsureCrmProductDevColumns() {
table := new(TenantCrmProduct).TableName()
cols := []string{
"line_type varchar(20) NOT NULL DEFAULT 'product' COMMENT '行类型:product成品/dev软件开发'",
"dev_unit_price decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '软件开发统一人天单价'",
"dev_tree text COMMENT '软件开发模块树JSON(叶子人天×统一人天单价)'",
}
for _, col := range cols {
sql := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s", table, col)
if _, err := Orm.Raw(sql).Exec(); err != nil {
// 列已存在或表不存在时忽略
continue
}
}
}
+1
View File
@@ -25,6 +25,7 @@ type TenantCrmContract struct {
SignDate *time.Time `orm:"column(sign_date);type(date);null" json:"sign_date"`
EffectiveDate *time.Time `orm:"column(effective_date);type(date);null" json:"effective_date"`
ExpireDate *time.Time `orm:"column(expire_date);type(date);null" json:"expire_date"`
TechDate *time.Time `orm:"column(tech_date);type(date);null" json:"tech_date"` // 技术日期(开发/交付完成日期;到期且回款未满则自动转执行异常)
Parties string `orm:"column(parties);type(text);null" json:"-"` // 各方签约主体 JSON 数组
Products string `orm:"column(products);type(text);null" json:"-"` // 产品清单 JSON 数组
HardwareAmount float64 `orm:"column(hardware_amount);digits(14);decimals(2);default(0)" json:"hardware_amount"`
+47
View File
@@ -0,0 +1,47 @@
package models
import "time"
// TenantCrmPayback 回款计划表: yz_backend_crm_payback
//
// 说明:
// - 回款计划针对于合同创建,contract_id 为关联合同(yz_backend_crm_contract.id);
// - plan_type 回款周期:1=月度 2=季度 3=年度 4=进度 5=自定义;
// - pay_method 回款方式:1=对公转账 2=网银转账 3=现金 4=支票 5=支付宝 6=微信 7=其他;
// - items 计划明细以 JSON 文本存储,结构随 plan_type 变化:
// 月度/季度/年度:{seq, name, amount, plan_date} —— 期数 + 支付金额 + 计划回款日期
// 进度: {seq, name, percent, amount, plan_date} —— 进度名称 + 进度百分比 + 进度金额 + 计划回款日期
// 自定义: {seq, name, plan_date, amount} —— 名称 + 计划日期 + 金额
// 每行另带 received_amount / received_date / status,用于「回款进度」登记实收。
// - plan_date 计划回款日期随各 plan_type 的明细行一并存储于 items JSON,无需独立列。
// - total_amount 计划回款总额;received_amount 已回款金额,均由 items 汇总。
type TenantCrmPayback struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
ContractID *uint64 `orm:"column(contract_id);null" json:"contract_id"` // 关联合同ID
ContractNo string `orm:"column(contract_no);size(50)" json:"contract_no"`
ContractName string `orm:"column(contract_name);size(100)" json:"contract_name"`
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"`
CustomerName string `orm:"column(customer_name);size(128)" json:"customer_name"`
PlanType int8 `orm:"column(plan_type);default(1)" json:"plan_type"` // 回款周期:1月度/2季度/3年度/4进度/5自定义
PayMethod int8 `orm:"column(pay_method);default(1)" json:"pay_method"` // 回款方式:1对公/2网银/3现金/4支票/5支付宝/6微信/7其他
RemindDays int `orm:"column(remind_days);default(0)" json:"remind_days"` // 提前几天提醒
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
ContractAmount float64 `orm:"column(contract_amount);digits(14);decimals(2);default(0)" json:"contract_amount"` // 合同总金额
TotalAmount float64 `orm:"column(total_amount);digits(14);decimals(2);default(0)" json:"total_amount"` // 计划回款总额
ReceivedAmount float64 `orm:"column(received_amount);digits(14);decimals(2);default(0)" json:"received_amount"` // 已回款金额
Status int8 `orm:"column(status);default(1)" json:"status"` // 1进行中/2已完成
Items string `orm:"column(items);type(text);null" json:"-"` // 计划明细 JSON 数组
Remark string `orm:"column(remark);type(text);null" json:"remark"`
ReceiptURL string `orm:"column(receipt_url);size(512);null" json:"receipt_url"` // 流水回执文件地址(选填)
ReceiptName string `orm:"column(receipt_name);size(255);null" json:"receipt_name"` // 流水回执文件名(选填)
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *TenantCrmPayback) TableName() string {
return "yz_backend_crm_payback"
}
+6 -1
View File
@@ -1,6 +1,8 @@
package models
import "time"
import (
"time"
)
// TenantCrmProduct CRM 产品管理(产品台账/目录): yz_backend_crm_product
//
@@ -19,6 +21,9 @@ type TenantCrmProduct struct {
Price float64 `orm:"column(price);digits(14);decimals(2);default(0)" json:"price"` // 销售单价
CostPrice float64 `orm:"column(cost_price);digits(14);decimals(2);default(0)" json:"cost_price"` // 成本单价
TaxRate float64 `orm:"column(tax_rate);digits(6);decimals(2);default(0)" json:"tax_rate"` // 税率(%)
LineType string `orm:"column(line_type);size(20);default(product)" json:"line_type"` // 行类型 product/dev
DevUnitPrice float64 `orm:"column(dev_unit_price);digits(14);decimals(2);default(0)" json:"dev_unit_price"` // 软件开发统一人天单价
DevTree string `orm:"column(dev_tree);type(text);null" json:"dev_tree"` // 软件开发模块树JSON文本
Status int8 `orm:"column(status);default(1)" json:"status"` // 1启用/0停用
ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 来源项目ID(由项目生成时填入)
ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 来源项目名称
+10
View File
@@ -426,12 +426,22 @@ func registerOrganizationRoutes(module string) {
beego.Router("/backend/crm/contract/:id", &controllers.BackendCrmContractController{}, "get:Detail;put:Update;delete:Delete")
beego.Router("/backend/crm/contract/:id/status", &controllers.BackendCrmContractController{}, "post:ChangeStatus")
// CRM回款管理(针对于合同:回款计划创建 + 回款进度登记)
beego.Router("/backend/crm/payback/list", &controllers.BackendCrmPaybackController{}, "get:List")
beego.Router("/backend/crm/payback/progress/list", &controllers.BackendCrmPaybackController{}, "get:ProgressList")
beego.Router("/backend/crm/payback", &controllers.BackendCrmPaybackController{}, "post:Create")
beego.Router("/backend/crm/payback/:id", &controllers.BackendCrmPaybackController{}, "get:Detail;put:Update;delete:Delete")
beego.Router("/backend/crm/payback/:id/receive", &controllers.BackendCrmPaybackController{}, "post:Receive")
// CRM回访记录(贯穿线索/商机/项目)
beego.Router("/backend/crm/follow/list", &controllers.BackendCrmFollowController{}, "get:List")
beego.Router("/backend/crm/follow/add", &controllers.BackendCrmFollowController{}, "post:Add")
beego.Router("/backend/crm/follow/edit", &controllers.BackendCrmFollowController{}, "post:Edit")
beego.Router("/backend/crm/follow/delete", &controllers.BackendCrmFollowController{}, "post:Delete")
// CRM数据仪表盘(按时间维度聚合真实业务数据)
beego.Router("/backend/crm/dashboard", &controllers.BackendCrmDashboardController{}, "get:Summary")
// CRM附件(线索/商机/项目资料)
beego.Router("/backend/crm/attach/list", &controllers.BackendCrmAttachController{}, "get:List")
beego.Router("/backend/crm/attach/add", &controllers.BackendCrmAttachController{}, "post:Add")