diff --git a/backend/components.d.ts b/backend/components.d.ts index 50d3e79..dceff9a 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -21,12 +21,14 @@ declare module 'vue' { ElButton: typeof import('element-plus/es')['ElButton'] ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup'] ElCard: typeof import('element-plus/es')['ElCard'] + ElCascader: typeof import('element-plus/es')['ElCascader'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCol: typeof import('element-plus/es')['ElCol'] ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition'] + ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] diff --git a/backend/src/api/crmContract.js b/backend/src/api/crmContract.js new file mode 100644 index 0000000..9a3ee5f --- /dev/null +++ b/backend/src/api/crmContract.js @@ -0,0 +1,46 @@ +import request from "@/utils/request"; + +/** + * CRM 合同管理接口 + * + * 说明: + * - 合同绑定项目:project_id 为空即为「无头合同」,否则为「项目合同」; + * - 合同状态 status:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常; + * - 参与方 parties / 产品清单 products / 金额汇总 summary 随主表一并以 JSON 提交, + * 后端按需落库;step 记录进度式创建所到达的步骤,支持每步保存续填。 + */ + +/** 合同列表 */ +export function getContractList(params) { + return request({ url: "/backend/crm/contract/list", method: "get", params }); +} + +/** 合同详情 */ +export function getContractDetail(id) { + return request({ url: `/backend/crm/contract/${id}`, method: "get" }); +} + +/** 创建合同(首次保存草稿,返回 id 后续转为更新) */ +export function createContract(data) { + return request({ url: "/backend/crm/contract", method: "post", data }); +} + +/** 更新合同(进度式保存每一步都走这里) */ +export function updateContract(id, data) { + return request({ url: `/backend/crm/contract/${id}`, method: "put", data }); +} + +/** 删除合同 */ +export function deleteContract(id) { + return request({ url: `/backend/crm/contract/${id}`, method: "delete" }); +} + +/** 合同状态流转:status 1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常 */ +export function changeContractStatus(id, status) { + return request({ url: `/backend/crm/contract/${id}/status`, method: "post", data: { status } }); +} + +/** 合同统计(列表页顶部卡片) */ +export function getContractStats(params) { + return request({ url: "/backend/crm/contract/stats", method: "get", params }); +} diff --git a/backend/src/views/apps/crm/bidding/components/index.vue b/backend/src/views/apps/crm/bidding/components/index.vue index e69de29..923a537 100644 --- a/backend/src/views/apps/crm/bidding/components/index.vue +++ b/backend/src/views/apps/crm/bidding/components/index.vue @@ -0,0 +1,5 @@ + + + diff --git a/backend/src/views/apps/crm/contract/components/PartySelect.vue b/backend/src/views/apps/crm/contract/components/PartySelect.vue new file mode 100644 index 0000000..7a49139 --- /dev/null +++ b/backend/src/views/apps/crm/contract/components/PartySelect.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/backend/src/views/apps/crm/contract/components/ProductList.vue b/backend/src/views/apps/crm/contract/components/ProductList.vue new file mode 100644 index 0000000..13d6b84 --- /dev/null +++ b/backend/src/views/apps/crm/contract/components/ProductList.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/backend/src/views/apps/crm/contract/components/QuickCreateParty.vue b/backend/src/views/apps/crm/contract/components/QuickCreateParty.vue new file mode 100644 index 0000000..d4af14f --- /dev/null +++ b/backend/src/views/apps/crm/contract/components/QuickCreateParty.vue @@ -0,0 +1,143 @@ + + + diff --git a/backend/src/views/apps/crm/contract/components/create.vue b/backend/src/views/apps/crm/contract/components/create.vue new file mode 100644 index 0000000..788b50c --- /dev/null +++ b/backend/src/views/apps/crm/contract/components/create.vue @@ -0,0 +1,723 @@ + + + + + diff --git a/backend/src/views/apps/crm/contract/components/detail.vue b/backend/src/views/apps/crm/contract/components/detail.vue new file mode 100644 index 0000000..9fc1ebd --- /dev/null +++ b/backend/src/views/apps/crm/contract/components/detail.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/backend/src/views/apps/crm/contract/components/utils.js b/backend/src/views/apps/crm/contract/components/utils.js new file mode 100644 index 0000000..b7fd0dc --- /dev/null +++ b/backend/src/views/apps/crm/contract/components/utils.js @@ -0,0 +1,62 @@ +/** + * 合同组件工具:金额汇总计算(产品清单 → 各部分金额 / 总金额 / 总成本 / 总利润) + * + * 金额归属: + * - 硬件部分 = Σ 产品类别为「硬件(货物)」的小计 + * - 软件部分 = Σ 产品类别为「软件(许可)」的小计 + * - 其他部分 = Σ 服务 / 开发 / 其他类小计 + * - 合同总金额 = 硬件 + 软件 + 其他 + * - 产品总成本 = Σ (数量 × 成本单价) + * - 合同总利润 = 合同总金额 - 产品总成本 + */ + +const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100; + +export function buildSummary(products) { + const rows = Array.isArray(products) ? products : []; + let hardware = 0; + let software = 0; + let other = 0; + let totalCost = 0; + rows.forEach((row) => { + const qty = Number(row?.quantity) || 0; + const price = Number(row?.price) || 0; + const costPrice = Number(row?.cost_price) || 0; + const amount = round2(qty * price); + totalCost += round2(qty * costPrice); + const cat = String(row?.category || ""); + if (cat === "1") hardware += amount; + else if (cat === "2") software += amount; + else other += amount; + }); + hardware = round2(hardware); + software = round2(software); + other = round2(other); + const totalAmount = round2(hardware + software + other); + return { + hardware_amount: hardware, + software_amount: software, + other_amount: other, + total_amount: totalAmount, + total_cost: round2(totalCost), + total_profit: round2(totalAmount - round2(totalCost)), + }; +} + +/** 生成合同编号建议值:HT-YYYYMMDD-4位随机 */ +export function genContractNo() { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const rand = String(Math.floor(Math.random() * 10000)).padStart(4, "0"); + return `HT-${y}${m}${day}-${rand}`; +} + +/** 甲乙丙丁角色 key,按 party_count 取前 n 个 */ +export const PARTY_ROLES = [ + { key: "party_a", label: "甲方" }, + { key: "party_b", label: "乙方" }, + { key: "party_c", label: "丙方" }, + { key: "party_d", label: "丁方" }, +]; diff --git a/backend/src/views/apps/crm/contract/index.vue b/backend/src/views/apps/crm/contract/index.vue index e69de29..865cb86 100644 --- a/backend/src/views/apps/crm/contract/index.vue +++ b/backend/src/views/apps/crm/contract/index.vue @@ -0,0 +1,371 @@ + + + + + + + diff --git a/backend/src/views/apps/crm/dict.js b/backend/src/views/apps/crm/dict.js index e72f4fd..6cb2c23 100644 --- a/backend/src/views/apps/crm/dict.js +++ b/backend/src/views/apps/crm/dict.js @@ -263,6 +263,110 @@ export function formatDateOnly(val) { return `${d.getFullYear()}-${m}-${day}`; } +/* ===================================================================== + * 合同管理 + * 我方角色 our_role:1=甲方 2=乙方 3=丙方 4=丁方(当前租户扮演的一方,默认乙方) + * 合同形式 party_count:2=双方(甲乙)3=三方(甲乙丙)4=四方(甲乙丙丁) + * 参与方来源 ref_type:1=客户 2=供应商 0=未关联 + * ===================================================================== */ + +/** 合同分类 */ +export const CONTRACT_CATEGORY_OPTIONS = [ + { label: "开发合同", value: "1" }, + { label: "服务合同", value: "2" }, + { label: "销售合同", value: "3" }, + { label: "租赁合同", value: "4" }, + { label: "采购合同", value: "5" }, + { label: "运维合同", value: "6" }, + { label: "咨询合同", value: "7" }, + { label: "其他", value: "8" }, +]; + +/** + * 我方角色:当前租户在合同中扮演的参与方(甲乙丙丁)。 + * 1=甲方 2=乙方 3=丙方 4=丁方,默认乙方。 + */ +export const OUR_ROLE_OPTIONS = [ + { label: "甲方", value: 1 }, + { label: "乙方", value: 2 }, + { label: "丙方", value: 3 }, + { label: "丁方", value: 4 }, +]; + +/** 合同形式(参与方数量) */ +export const CONTRACT_PARTY_COUNT_OPTIONS = [ + { label: "双方合同", value: 2 }, + { label: "三方合同", value: 3 }, + { label: "四方合同", value: 4 }, +]; + +/** 合同状态:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常 */ +export const CONTRACT_STATUS_OPTIONS = [ + { label: "草稿", value: "1" }, + { label: "已完成", value: "2" }, + { label: "履约中", value: "4" }, + { label: "执行异常", value: "5" }, + { label: "已作废", value: "3" }, +]; + +/** 产品类别:金额归属 硬件/软件 归各自部分,其余计入其他部分 */ +export const CONTRACT_PRODUCT_CATEGORY_OPTIONS = [ + { label: "硬件(货物)", value: "1" }, + { label: "软件(许可)", value: "2" }, + { label: "服务", value: "3" }, + { label: "开发", value: "4" }, + { label: "其他", value: "5" }, +]; + +/** 参与方角色(甲乙丙丁) */ +export const PARTY_ROLE_OPTIONS = [ + { key: "party_a", label: "甲方" }, + { key: "party_b", label: "乙方" }, + { key: "party_c", label: "丙方" }, + { key: "party_d", label: "丁方" }, +]; + +const CONTRACT_CATEGORY_MAP = CONTRACT_CATEGORY_OPTIONS.reduce( + (m, i) => ((m[i.value] = i.label), m), + {} +); +const OUR_ROLE_MAP = OUR_ROLE_OPTIONS.reduce( + (m, i) => ((m[i.value] = i.label), m), + {} +); +const CONTRACT_STATUS_MAP = CONTRACT_STATUS_OPTIONS.reduce( + (m, i) => ((m[i.value] = i.label), m), + {} +); +const PRODUCT_CATEGORY_MAP = CONTRACT_PRODUCT_CATEGORY_OPTIONS.reduce( + (m, i) => ((m[i.value] = i.label), m), + {} +); + +const CONTRACT_STATUS_TAG = { 1: "warning", 2: "success", 3: "info", 4: "primary", 5: "danger" }; +const PRODUCT_CATEGORY_TAG = { 1: "warning", 2: "primary", 3: "success", 4: "danger", 5: "info" }; + +export const contractCategoryText = (val) => + CONTRACT_CATEGORY_MAP[normalize(val)] || normalize(val) || "-"; + +/** 我方角色文案:1甲方/2乙方/3丙方/4丁方 */ +export const ourRoleText = (val) => + OUR_ROLE_MAP[normalize(val)] || normalize(val) || "-"; +export const ourRoleTag = () => "primary"; +export const contractStatusText = (val) => + CONTRACT_STATUS_MAP[normalize(val)] || normalize(val) || "-"; +export const contractStatusTag = (val) => CONTRACT_STATUS_TAG[normalize(val)] || "info"; +export const productCategoryText = (val) => + PRODUCT_CATEGORY_MAP[normalize(val)] || normalize(val) || "-"; +export const productCategoryTag = (val) => PRODUCT_CATEGORY_TAG[normalize(val)] || "info"; + +/** 合同分类选项(供 el-select 遍历) */ +export const contractCategoryOptions = CONTRACT_CATEGORY_OPTIONS; +export const contractStatusOptions = CONTRACT_STATUS_OPTIONS; +export const ourRoleOptions = OUR_ROLE_OPTIONS; +export const contractPartyCountOptions = CONTRACT_PARTY_COUNT_OPTIONS; +export const contractProductCategoryOptions = CONTRACT_PRODUCT_CATEGORY_OPTIONS; + /** 富文本转纯文本预览(用于列表展示,含图片时返回 [图片]) */ export function stripHtml(val) { if (!val) return "-"; diff --git a/backend/src/views/apps/crm/payback/index.vue b/backend/src/views/apps/crm/payback/index.vue index e69de29..923a537 100644 --- a/backend/src/views/apps/crm/payback/index.vue +++ b/backend/src/views/apps/crm/payback/index.vue @@ -0,0 +1,5 @@ + + + diff --git a/go/controllers/backend_crm_contract.go b/go/controllers/backend_crm_contract.go new file mode 100644 index 0000000..3fdc8a9 --- /dev/null +++ b/go/controllers/backend_crm_contract.go @@ -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 +} diff --git a/go/models/init.go b/go/models/init.go index 99e65de..50adf65 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -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 文档库)。 diff --git a/go/models/tenant_crm_contract.go b/go/models/tenant_crm_contract.go new file mode 100644 index 0000000..aefa242 --- /dev/null +++ b/go/models/tenant_crm_contract.go @@ -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" +} diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index 9cf6d65..b35ee88 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -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") diff --git a/sql/yz_backend_crm_contract.sql b/sql/yz_backend_crm_contract.sql new file mode 100644 index 0000000..019256d --- /dev/null +++ b/sql/yz_backend_crm_contract.sql @@ -0,0 +1,53 @@ +-- ============================================================================= +-- CRM 合同管理:进度式创建(合同信息 / 产品清单),绑定项目或无头合同 +-- 对应租户端 backend 页面:/apps/crm/contract +-- 说明: +-- 1. project_id 为空即为「无头合同」,否则为「项目合同」; +-- 2. our_role:我方角色 1=甲方 2=乙方 3=丙方 4=丁方(当前租户扮演的一方,默认乙方); +-- party_count:2=双方(甲乙)3=三方(甲乙丙)4=四方(甲乙丙丁); +-- 3. parties / products 以 JSON 文本存储; +-- 4. 各部分金额冗余为表字段,由后端按 products 重算,便于统计与排序; +-- 5. status:1=草稿(进度式保存中)2=已完成 3=已作废;step 记录创建进度。 +-- ⚠️ 安全说明:本脚本只做 CREATE TABLE IF NOT EXISTS,不删除或覆盖任何已有数据,可重复执行。 +-- ============================================================================= + +SET NAMES utf8mb4; + +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(yz_backend_crm_project.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:[{role,ref_type,ref_id,ref_name,signer_name,signer_phone}]', + `products` text COMMENT '产品清单 JSON:[{name,category,spec,unit,quantity,price,cost_price,remark}]', + `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合同表';