新增合同管理相关功能

This commit is contained in:
2026-09-11 18:22:22 +08:00
parent 840673bda5
commit 184c604bee
17 changed files with 2944 additions and 0 deletions
+604
View File
@@ -0,0 +1,604 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"math/rand"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendCrmContractController CRM 合同管理
//
// 进度式创建:前端每完成一步即可保存(Create / Update 均支持),
// step 记录创建进度(1=合同信息 2=产品清单),status=1 表示草稿、2 表示已完成。
type BackendCrmContractController struct {
beego.Controller
}
// contractSummary 金额汇总(后端按 products 重算,与前端展示逻辑一致)。
type contractSummary struct {
HardwareAmount float64 `json:"hardware_amount"` // 硬件部分金额
SoftwareAmount float64 `json:"software_amount"` // 软件部分金额
OtherAmount float64 `json:"other_amount"` // 其他部分金额(服务/开发/其他)
TotalAmount float64 `json:"total_amount"` // 合同总金额 = 硬件 + 软件 + 其他
TotalCost float64 `json:"total_cost"` // 产品总成本 = Σ(数量 × 成本单价)
TotalProfit float64 `json:"total_profit"` // 合同总利润 = 总金额 - 总成本
}
// contractPayload 创建 / 更新请求体。
type contractPayload struct {
ContractNo string `json:"contract_no"`
ContractName string `json:"contract_name"`
ContractCategory string `json:"contract_category"`
OurRole int8 `json:"our_role"` // 我方角色:1甲方/2乙方/3丙方/4丁方
PartyCount int8 `json:"party_count"`
ProjectID uint64 `json:"project_id"`
ProjectName string `json:"project_name"`
OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"`
SignDate string `json:"sign_date"`
EffectiveDate string `json:"effective_date"`
ExpireDate string `json:"expire_date"`
Parties json.RawMessage `json:"parties"`
Products json.RawMessage `json:"products"`
Step int8 `json:"step"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
// contractResp 列表 / 详情响应:parties / products 解析为 JSON 数组透出,summary 由金额字段组装。
type contractResp struct {
models.TenantCrmContract
Parties json.RawMessage `json:"parties"`
Products json.RawMessage `json:"products"`
Summary *contractSummary `json:"summary"`
}
// List GET /backend/crm/contract/list
func (c *BackendCrmContractController) 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"))
ourRole := strings.TrimSpace(c.GetString("our_role"))
category := strings.TrimSpace(c.GetString("contract_category"))
projectType := strings.TrimSpace(c.GetString("project_type"))
status := strings.TrimSpace(c.GetString("status"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if keyword != "" {
// 签约主体名称存储在 parties JSON 中,用 LIKE 一并匹配
kw := orm.NewCondition().
Or("contract_name__contains", keyword).
Or("contract_no__contains", keyword).
Or("project_name__contains", keyword).
Or("owner_user_name__contains", keyword).
Or("parties__contains", keyword)
cond = cond.AndCond(kw)
}
if ourRole != "" {
cond = cond.And("our_role", ourRole)
}
if category != "" {
cond = cond.And("contract_category", category)
}
if status != "" {
cond = cond.And("status", status)
}
switch projectType {
case "project": // 项目合同
cond = cond.And("project_id__gt", 0)
case "headless": // 无头合同
cond = cond.And("project_id__isnull", true)
}
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmContract
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
items := make([]contractResp, 0, len(list))
for i := range list {
items = append(items, buildContractResp(&list[i]))
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": items, "total": total, "page": page, "pageSize": pageSize,
})
}
// Stats GET /backend/crm/contract/stats
// 全租户合同统计(排除已作废),供列表页顶部汇总卡片使用。
func (c *BackendCrmContractController) Stats() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
tenantID := pipelineTenantID(claims)
table := new(models.TenantCrmContract).TableName()
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"
var rows []orm.Params
if _, err := models.Orm.Raw(raw, tenantID).Values(&rows); err != nil || len(rows) == 0 {
pipelineOk(&c.Controller, contractSummary{TotalProfit: 0})
return
}
r := rows[0]
pipelineOk(&c.Controller, map[string]interface{}{
"total": toInt64(r["total"]),
"total_amount": toFloat64(r["total_amount"]),
"total_cost": toFloat64(r["total_cost"]),
"total_profit": toFloat64(r["total_profit"]),
})
}
// Detail GET /backend/crm/contract/:id
func (c *BackendCrmContractController) 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.TenantCrmContract
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
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, buildContractResp(&row))
}
// Create POST /backend/crm/contract
// 进度式保存入口之一:首次保存(通常为草稿),返回 id 后续走 Update。
func (c *BackendCrmContractController) 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 contractPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if strings.TrimSpace(p.ContractName) == "" {
pipelineErr(&c.Controller, 400, 400, "合同名称不能为空")
return
}
tenantID := pipelineTenantID(claims)
now := time.Now()
row := models.TenantCrmContract{
TenantID: tenantID,
ContractNo: strings.TrimSpace(p.ContractNo),
ContractName: strings.TrimSpace(p.ContractName),
OurRole: pickInt8(p.OurRole, 2, 4),
PartyCount: pickInt8(p.PartyCount, 2, 4),
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
SignDate: parsePipelineDate(p.SignDate),
EffectiveDate: parsePipelineDate(p.EffectiveDate),
ExpireDate: parsePipelineDate(p.ExpireDate),
Status: pickInt8(p.Status, 1, 5),
Step: pickInt8(p.Step, 1, 2),
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
}
if strings.TrimSpace(p.ContractCategory) != "" {
row.ContractCategory = strings.TrimSpace(p.ContractCategory)
}
// 编号为空时自动生成(查重)
if row.ContractNo == "" {
row.ContractNo = genContractNo(tenantID)
}
// 绑定项目:校验项目归属并取标准项目名称
if p.ProjectID > 0 {
projID, projName, ok := c.resolveProject(tenantID, p.ProjectID)
if !ok {
pipelineErr(&c.Controller, 400, 400, "关联项目不存在")
return
}
row.ProjectID = &projID
row.ProjectName = projName
}
// 参与方 / 产品清单 JSON 落库 + 金额重算
parties, err := normalizeContractJSON(p.Parties)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "参与方数据格式错误")
return
}
row.Parties = parties
products, err := normalizeContractJSON(p.Products)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "产品清单数据格式错误")
return
}
row.Products = products
applyContractAmounts(&row)
if _, err := models.Orm.Insert(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(tenantID, 3, row.ID, "create", "创建合同:"+row.ContractName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
// Update PUT /backend/crm/contract/:id
// 进度式保存入口之一:每一步保存都走这里,全量覆盖业务字段。
func (c *BackendCrmContractController) 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 contractPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmContract
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
if strings.TrimSpace(p.ContractName) == "" {
pipelineErr(&c.Controller, 400, 400, "合同名称不能为空")
return
}
row.ContractName = strings.TrimSpace(p.ContractName)
if no := strings.TrimSpace(p.ContractNo); no != "" {
row.ContractNo = no
}
if strings.TrimSpace(p.ContractCategory) != "" {
row.ContractCategory = strings.TrimSpace(p.ContractCategory)
}
if p.OurRole != 0 {
row.OurRole = p.OurRole
}
if p.PartyCount != 0 {
row.PartyCount = p.PartyCount
}
// 项目绑定支持切换 / 解绑(清空即为无头合同)
if p.ProjectID > 0 {
projID, projName, ok := c.resolveProject(tenantID, p.ProjectID)
if !ok {
pipelineErr(&c.Controller, 400, 400, "关联项目不存在")
return
}
row.ProjectID = &projID
row.ProjectName = projName
} else {
row.ProjectID = nil
row.ProjectName = ""
}
if strings.TrimSpace(p.OwnerUserID) != "" {
row.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
}
if strings.TrimSpace(p.OwnerUserName) != "" {
row.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
}
row.SignDate = parsePipelineDate(p.SignDate)
row.EffectiveDate = parsePipelineDate(p.EffectiveDate)
row.ExpireDate = parsePipelineDate(p.ExpireDate)
if p.Parties != nil {
parties, err := normalizeContractJSON(p.Parties)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "参与方数据格式错误")
return
}
row.Parties = parties
}
if p.Products != nil {
products, err := normalizeContractJSON(p.Products)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "产品清单数据格式错误")
return
}
row.Products = products
}
applyContractAmounts(&row)
if p.Status != 0 {
row.Status = p.Status
}
if p.Step != 0 {
row.Step = p.Step
}
row.Remark = p.Remark
row.UpdateTime = time.Now()
if _, err := models.Orm.Update(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
crmWriteLog(tenantID, 3, row.ID, "update", "更新合同:"+row.ContractName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
// Delete DELETE /backend/crm/contract/:id
func (c *BackendCrmContractController) 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.TenantCrmContract
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
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.TenantCrmContract)).
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)
}
// ChangeStatus POST /backend/crm/contract/:id/status
// 合同状态流转:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常。
// 向导创建的合同默认为草稿,签订 / 履约等状态在列表中手动流转;各状态间可互切。
func (c *BackendCrmContractController) ChangeStatus() {
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 {
Status int8 `json:"status"`
}
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
// 合法目标状态:2已完成 / 3已作废 / 4履约中 / 5执行异常
if p.Status < 2 || p.Status > 5 {
pipelineErr(&c.Controller, 400, 400, "无效的状态值")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmContract
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
if row.Status == p.Status {
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": row.Status})
return
}
if _, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(map[string]interface{}{"status": p.Status, "update_time": time.Now()}); err != nil {
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
return
}
crmWriteLog(tenantID, 3, row.ID, "status", fmt.Sprintf("合同状态流转:%s → %s", contractStatusName(row.Status), contractStatusName(p.Status)), claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": p.Status})
}
// contractStatusName 状态文案(用于操作日志)。
func contractStatusName(s int8) string {
switch s {
case 1:
return "草稿"
case 2:
return "已完成"
case 3:
return "已作废"
case 4:
return "履约中"
case 5:
return "执行异常"
default:
return "未知"
}
}
// ========================== 内部辅助 ==========================
// buildContractResp 组装响应:parties / products 透传 JSON 数组,summary 由金额字段组装。
func buildContractResp(row *models.TenantCrmContract) contractResp {
parties := json.RawMessage("[]")
if strings.TrimSpace(row.Parties) != "" {
parties = json.RawMessage(row.Parties)
}
products := json.RawMessage("[]")
if strings.TrimSpace(row.Products) != "" {
products = json.RawMessage(row.Products)
}
return contractResp{
TenantCrmContract: *row,
Parties: parties,
Products: products,
Summary: &contractSummary{
HardwareAmount: row.HardwareAmount,
SoftwareAmount: row.SoftwareAmount,
OtherAmount: row.OtherAmount,
TotalAmount: row.TotalAmount,
TotalCost: row.TotalCost,
TotalProfit: row.TotalProfit,
},
}
}
// resolveProject 校验项目归属当前租户并返回 (id, 标准项目名称)。
func (c *BackendCrmContractController) resolveProject(tenantID string, projectID uint64) (uint64, string, bool) {
var proj models.TenantCrmProject
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", projectID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&proj); err != nil {
return 0, "", false
}
return proj.ID, proj.ProjectName, true
}
// normalizeContractJSON 校验并规范化 JSON 数组(参与方 / 产品清单),返回紧凑 JSON 文本。
func normalizeContractJSON(raw json.RawMessage) (string, error) {
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" {
return "[]", nil
}
var arr []map[string]interface{}
if err := json.Unmarshal(raw, &arr); err != nil {
return "", err
}
out, err := json.Marshal(arr)
if err != nil {
return "", err
}
return string(out), nil
}
// applyContractAmounts 按产品清单重算各部分金额(与前端 ProductList 汇总口径一致):
// 硬件部分=Σ硬件小计;软件部分=Σ软件小计;其他部分=Σ服务/开发/其他小计;
// 合同总金额=硬件+软件+其他;产品总成本=Σ(数量×成本单价);合同总利润=总金额-总成本。
func applyContractAmounts(row *models.TenantCrmContract) {
var items []map[string]interface{}
if strings.TrimSpace(row.Products) != "" {
_ = json.Unmarshal([]byte(row.Products), &items)
}
var hardware, software, other, cost float64
for _, item := range items {
qty := toFloat64(item["quantity"])
price := toFloat64(item["price"])
costPrice := toFloat64(item["cost_price"])
amount := round2(qty * price)
cost = round2(cost + round2(qty*costPrice))
cat := fmt.Sprintf("%v", item["category"])
switch cat {
case "1":
hardware = round2(hardware + amount)
case "2":
software = round2(software + amount)
default:
other = round2(other + amount)
}
}
row.HardwareAmount = hardware
row.SoftwareAmount = software
row.OtherAmount = other
row.TotalAmount = round2(hardware + software + other)
row.TotalCost = cost
row.TotalProfit = round2(row.TotalAmount - cost)
}
// genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。
func genContractNo(tenantID string) string {
for i := 0; i < 5; i++ {
no := fmt.Sprintf("HT-%s-%04d", time.Now().Format("20060102"), rand.Intn(10000))
count, _ := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("tenant_id", tenantID).Filter("contract_no", no).
Filter("delete_time__isnull", true).Count()
if count == 0 {
return no
}
}
return fmt.Sprintf("HT-%s-%d", time.Now().Format("20060102"), time.Now().UnixNano()%100000)
}
// pickInt8 取值约束:v 落在 [min, max] 内返回 v,否则返回 def。
func pickInt8(v, def, max int8) int8 {
if v >= 1 && v <= max {
return v
}
return def
}
// round2 保留两位小数。
func round2(n float64) float64 {
return float64(int64((n+1e-9)*100+0.5)) / 100
}
// toInt64 orm.Params 值转 int64。
func toInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case []byte:
x, _ := strconv.ParseInt(strings.TrimSpace(string(n)), 10, 64)
return x
case string:
x, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return x
}
return 0
}
// toFloat64 orm.Params / JSON 数值转 float64。
func toFloat64(v interface{}) float64 {
switch n := v.(type) {
case float64:
return n
case int64:
return float64(n)
case []byte:
x, _ := strconv.ParseFloat(strings.TrimSpace(string(n)), 64)
return x
case string:
x, _ := strconv.ParseFloat(strings.TrimSpace(n), 64)
return x
}
return 0
}
+58
View File
@@ -81,6 +81,7 @@ func Init(_ string) {
new(TenantCrmAttach),
new(TenantCrmEntityContact),
new(TenantCrmOperateLog),
new(TenantCrmContract),
new(ErpAccountSet),
new(ErpNormalSetting),
new(ErpCompanyContact),
@@ -142,6 +143,63 @@ func Init(_ string) {
EnsureCrmCustomerPoolColumns()
EnsureCrmCreateUserColumn()
EnsureCrmProjectDocColumn()
EnsureCrmContractTable()
EnsureCrmContractOurRoleColumn()
}
// EnsureCrmContractOurRoleColumn 补齐合同表的我方角色字段(存量表已建时新增;
// 旧版「合同性质 contract_nature」字段弃用但保留不动,表不存在或列已存在时忽略错误)。
func EnsureCrmContractOurRoleColumn() {
sql := "ALTER TABLE " + new(TenantCrmContract).TableName() +
" ADD COLUMN our_role tinyint NOT NULL DEFAULT 2 COMMENT '我方角色:1甲方/2乙方/3丙方/4丁方'"
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmContractTable 合同表建表(CREATE TABLE IF NOT EXISTS,可重复执行;
// 建表失败(如表已存在但结构不一致)时静默忽略,可用 sql/yz_backend_crm_contract.sql 手动修复)。
func EnsureCrmContractTable() {
sql := `CREATE TABLE IF NOT EXISTS yz_backend_crm_contract (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
contract_no varchar(50) NOT NULL DEFAULT '' COMMENT '合同编号',
contract_name varchar(100) NOT NULL COMMENT '合同名称',
contract_category varchar(20) NOT NULL DEFAULT '' COMMENT '合同分类:1开发/2服务/3销售/4租赁/5采购/6运维/7咨询/8其他',
our_role tinyint(4) NOT NULL DEFAULT '2' COMMENT '我方角色:1甲方/2乙方/3丙方/4丁方',
party_count tinyint(4) NOT NULL DEFAULT '2' COMMENT '合同形式:2/3/4方',
project_id bigint(20) DEFAULT NULL COMMENT '关联项目ID,空=无头合同',
project_name varchar(100) NOT NULL DEFAULT '' COMMENT '项目名称',
owner_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '项目负责人用户ID',
owner_user_name varchar(128) NOT NULL DEFAULT '' COMMENT '项目负责人姓名',
sign_date date DEFAULT NULL COMMENT '签订日期',
effective_date date DEFAULT NULL COMMENT '生效日期',
expire_date date DEFAULT NULL COMMENT '结束日期',
parties text COMMENT '各方签约主体JSON',
products text COMMENT '产品清单JSON',
hardware_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '硬件部分金额',
software_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '软件部分金额',
other_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '其他部分金额',
total_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总金额',
total_cost decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '产品总成本',
total_profit decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总利润',
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1草稿/2已完成/3已作废/4履约中/5执行异常',
step tinyint(4) NOT NULL DEFAULT '1' COMMENT '进度步骤:1合同信息/2产品清单',
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_name (tenant_id,contract_name),
KEY idx_contract_no (tenant_id,contract_no),
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合同表'`
if _, err := Orm.Raw(sql).Exec(); err != nil {
// 表已存在或执行失败时忽略(完整表结构见 sql/yz_backend_crm_contract.sql)
return
}
}
// EnsureCrmProjectDocColumn 补齐项目表的文档库文件夹分类字段(项目文档绑定 OA 文档库)。
+47
View File
@@ -0,0 +1,47 @@
package models
import "time"
// TenantCrmContract 合同表: yz_backend_crm_contract
//
// 说明:
// - project_id 为空即为「无头合同」,否则为「项目合同」;
// - our_role:我方角色,当前租户扮演的参与方,1=甲方 2=乙方 3=丙方 4=丁方(默认乙方);
// - party_count:2=双方(甲乙)3=三方(甲乙丙)4=四方(甲乙丙丁);
// - parties / products 以 JSON 文本存储,返回时由控制器解析后透出;
// - 各部分金额冗余为表字段,便于列表统计与排序(与 products 重算结果一致)。
type TenantCrmContract struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
ContractNo string `orm:"column(contract_no);size(50)" json:"contract_no"` // 合同编号
ContractName string `orm:"column(contract_name);size(100)" json:"contract_name"` // 合同名称
ContractCategory string `orm:"column(contract_category);size(20)" json:"contract_category"` // 分类:1开发/2服务/3销售/4租赁/5采购/6运维/7咨询/8其他
OurRole int8 `orm:"column(our_role);default(2)" json:"our_role"` // 我方角色:1甲方/2乙方/3丙方/4丁方
PartyCount int8 `orm:"column(party_count);default(2)" json:"party_count"` // 合同形式:2/3/4 方
ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 关联项目ID(空=无头合同)
ProjectName string `orm:"column(project_name);size(100)" json:"project_name"`
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"`
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"`
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"`
SoftwareAmount float64 `orm:"column(software_amount);digits(14);decimals(2);default(0)" json:"software_amount"`
OtherAmount float64 `orm:"column(other_amount);digits(14);decimals(2);default(0)" json:"other_amount"`
TotalAmount float64 `orm:"column(total_amount);digits(14);decimals(2);default(0)" json:"total_amount"`
TotalCost float64 `orm:"column(total_cost);digits(14);decimals(2);default(0)" json:"total_cost"`
TotalProfit float64 `orm:"column(total_profit);digits(14);decimals(2);default(0)" json:"total_profit"`
Status int8 `orm:"column(status);default(1)" json:"status"` // 1草稿/2已完成/3已作废/4履约中/5执行异常
Step int8 `orm:"column(step);default(1)" json:"step"` // 进度式创建步骤:1合同信息/2产品清单
Remark string `orm:"column(remark);type(text);null" json:"remark"`
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 *TenantCrmContract) TableName() string {
return "yz_backend_crm_contract"
}
+7
View File
@@ -408,6 +408,13 @@ func registerOrganizationRoutes(module string) {
beego.Router("/backend/crm/project/:id/doc-folder", &controllers.BackendCrmProjectController{}, "post:DocFolderCreate")
beego.Router("/backend/crm/project/:id/doc-folder-delete", &controllers.BackendCrmProjectController{}, "post:DocFolderDelete")
// CRM合同管理(进度式创建:合同信息/产品清单每步可保存;绑定项目或无头合同)
beego.Router("/backend/crm/contract/list", &controllers.BackendCrmContractController{}, "get:List")
beego.Router("/backend/crm/contract/stats", &controllers.BackendCrmContractController{}, "get:Stats")
beego.Router("/backend/crm/contract", &controllers.BackendCrmContractController{}, "post:Create")
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/follow/list", &controllers.BackendCrmFollowController{}, "get:List")
beego.Router("/backend/crm/follow/add", &controllers.BackendCrmFollowController{}, "post:Add")