优化合同个管理和产品管理相关功能
This commit is contained in:
@@ -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++ {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '生成该消息使用的模型';
|
||||
@@ -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');
|
||||
@@ -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产品分类表';
|
||||
@@ -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);
|
||||
@@ -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续杯激活码';
|
||||
@@ -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`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"` // 来源项目名称
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user