Files
yunzerwebsiteallinone/go/controllers/backend_crm_contract.go
T
2026-09-14 17:30:12 +08:00

979 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
// 我方角色:0=我方不参与(如上中游项目中上游与中游主体之间的合同,我方不是签约方)
// 1甲方/2乙方/3丙方/4丁方。用指针区分「未传」与「传 0」。
OurRole *int8 `json:"our_role"`
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"))
projectID := strings.TrimSpace(c.GetString("project_id"))
status := strings.TrimSpace(c.GetString("status"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
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)
}
// 按项目精确筛选(项目详情「合同管理」Tab:一个项目可关联多份合同)
if projectID != "" {
if pid, err := strconv.ParseUint(projectID, 10, 64); err == nil && pid > 0 {
cond = cond.And("project_id", pid)
}
}
// 周期筛选:与顶部统计同口径(合同有效期 [生效日期, 结束日期] 与所选周期重叠),
// period=month/quarter/year 取当期区间,period=custom 用 start_date / end_date
period := strings.TrimSpace(c.GetString("period"))
rangeStart := strings.TrimSpace(c.GetString("start_date"))
rangeEnd := strings.TrimSpace(c.GetString("end_date"))
if period != "" && period != "custom" {
rangeStart, rangeEnd = periodRange(period)
}
if rangeStart != "" {
cond = cond.AndCond(orm.NewCondition().Or("expire_date__isnull", true).Or("expire_date__gte", rangeStart))
}
if rangeEnd != "" {
cond = cond.AndCond(orm.NewCondition().Or("effective_date__isnull", true).Or("effective_date__lte", rangeEnd))
}
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmContract
if total > 0 {
offset := (page - 1) * pageSize
if status != "" {
// 已按状态筛选:结果只含单一状态,直接按新→旧分页
if _, err := qs.OrderBy("-id").Offset(offset).Limit(pageSize).All(&list); err != nil {
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
return
}
} else {
// 列表排列:按状态分组展示 —— 草稿(1) → 异常(5) → 履约中(4) → 已完成(2) → 已作废(3),
// 其余状态(NULL / 历史异常值)沉底;组内仍按新→旧。
// 先统计各组数量,把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
groupConds := []*orm.Condition{
orm.NewCondition().And("status", 1),
orm.NewCondition().And("status", 5),
orm.NewCondition().And("status", 4),
orm.NewCondition().And("status", 2),
orm.NewCondition().And("status", 3),
orm.NewCondition().AndNot("status__in", []int8{1, 5, 4, 2, 3}).Or("status__isnull", true),
}
end := offset + pageSize
if end > int(total) {
end = int(total)
}
cursor := 0
for _, gc := range groupConds {
if cursor >= end {
break
}
// 注意:beego 的 AndCond 会修改接收者,这里用新条件对象包装,避免循环内条件累积
groupCond := orm.NewCondition().AndCond(cond).AndCond(gc)
cnt, _ := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(groupCond).Count()
groupTotal := int(cnt)
if groupTotal == 0 {
continue
}
groupStart := cursor
groupEnd := cursor + groupTotal
cursor = groupEnd
// 当前页与本组区间的交集
from, to := max(offset, groupStart), min(end, groupEnd)
if to <= from {
continue
}
var part []models.TenantCrmContract
if _, err := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(groupCond).
OrderBy("-id").Offset(from - groupStart).Limit(to - from).All(&part); err != nil {
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
return
}
list = append(list, part...)
}
}
}
// 回款逾期日期(取最早,未收齐且已过计划回款日期):合同「逾期」标签与结束日期口径一并参考
if len(list) > 0 {
contractIDs := make([]uint64, 0, len(list))
for i := range list {
contractIDs = append(contractIDs, list[i].ID)
}
overdues := paybackOverdueDateByContracts(tenantID, contractIDs)
for i := range list {
list[i].PaybackOverdueDate = overdues[list[i].ID]
}
}
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
// 全租户合同统计(排除已作废),供列表页顶部汇总卡片使用。
// 支持按周期筛选:period=month/quarter/year(当期)或 custom(start_date~end_date),
// 按合同有效期 [生效日期, 结束日期] 与所选周期重叠统计。
// 「合同总利润」仅统计非甲方(收款方)合同:我方为甲方的合同是采购付款,只有支出、不存在利润。
func (c *BackendCrmContractController) Stats() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
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()
// 我方不参与的合同(our_role=0,如上中游项目的链条合同)不属于我方经营合同,不计入经营统计
where := "tenant_id = ? AND delete_time IS NULL AND status <> 3 AND our_role <> 0"
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(CASE WHEN our_role <> 1 THEN total_profit ELSE 0 END),0) AS total_profit " +
"FROM " + table + " WHERE " + where
var rows []orm.Params
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]
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"]),
})
}
// 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)
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
}
// 数据范围校验:非管理员仅能查看本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权查看该合同数据")
return
}
// 回款逾期日期(取最早,未收齐且已过计划回款日期):合同「逾期」标签与结束日期口径一并参考
if od := paybackOverdueDateByContracts(pipelineTenantID(claims), []uint64{row.ID}); od[row.ID] != "" {
row.PaybackOverdueDate = od[row.ID]
}
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()
ourRole := int8(2)
if p.OurRole != nil {
ourRole = *p.OurRole
}
row := models.TenantCrmContract{
TenantID: tenantID,
ContractNo: strings.TrimSpace(p.ContractNo),
ContractName: strings.TrimSpace(p.ContractName),
OurRole: normalizeOurRole(ourRole),
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
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行)
projID := uint64(0)
if row.ProjectID != nil {
projID = *row.ProjectID
}
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), projID, row.ProjectName, json.RawMessage(products)); serr == nil {
row.Products = string(synced)
}
applyContractAmounts(&row)
// 唯一性校验:同租户内「合同名称 + 签约方」完全一致才视为重复。
// 同名但签约方不同(例如我方分别作为甲方 / 乙方的两份合同)不算重复。
if crmContractDuplicated(tenantID, row.ContractName, row.Parties) {
pipelineErr(&c.Controller, 409, 409, "已存在同名且签约方相同的合同(可能由其他同事创建),如需继续跟进请联系管理员转移")
return
}
if _, err := models.Orm.Insert(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
// 参与方填写的签约人(姓名 + 电话)同步写入对应企业的联系人通讯录
syncContractPartyContacts(tenantID, row.Parties)
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 !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
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 != nil {
// 0=我方不参与也是合法值,用指针判断避免误吞
row.OurRole = normalizeOurRole(*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
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行)
projID := uint64(0)
if row.ProjectID != nil {
projID = *row.ProjectID
}
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), projID, row.ProjectName, json.RawMessage(products)); serr == nil {
row.Products = string(synced)
}
}
applyContractAmounts(&row)
// 状态不随合同编辑变更(避免编辑保存把已流转的状态打回草稿);
// 签订/履约/作废等流转统一由「状态流转」接口 ChangeStatus 控制。
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
}
// 参与方填写的签约人(姓名 + 电话)同步写入对应企业的联系人通讯录
syncContractPartyContacts(tenantID, row.Parties)
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 !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
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 !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
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 数组;
// 金额字段在返回前按 products 以当前口径重算(单价含税、不再叠加税率),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)
}
// 返回前按「单价含税、不再叠加税率」的口径重算金额,
// 保证列表/详情与产品清单一致,并自动修正历史数据按旧口径(叠加税率)存的金额
if strings.TrimSpace(row.Products) != "" && string(products) != "[]" {
applyContractAmounts(row)
}
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,
},
}
}
// syncContractPartyContacts 把合同参与方填写的签约人(姓名 + 电话)写入对应企业的联系人通讯录:
// 新建的客户 / 供应商往往还没有联系人,用户只能手填签约人姓名与电话,
// 这里自动建档(仅姓名 + 电话,其余资料留空,可在联系人管理中继续补全),
// 避免这些信息只停留在合同文本里;同名或同号已存在时不会重复建档。
// 仅处理关联了客户 / 供应商库的参与方:本公司方(ref_type=0)与手工填写未关联的名称(ref_id=0)跳过。
func syncContractPartyContacts(tenantID, partiesJSON string) {
partiesJSON = strings.TrimSpace(partiesJSON)
if models.Orm == nil || partiesJSON == "" || partiesJSON == "[]" {
return
}
var parties []struct {
RefType int `json:"ref_type"`
RefID uint64 `json:"ref_id"`
SignerName string `json:"signer_name"`
SignerPhone string `json:"signer_phone"`
}
if err := json.Unmarshal([]byte(partiesJSON), &parties); err != nil {
return
}
for _, p := range parties {
// ref_type:0=本公司 1=客户 2=供应商;未关联库(ref_id=0)时无处可写
if p.RefID == 0 || (p.RefType != 1 && p.RefType != 2) {
continue
}
name := strings.TrimSpace(p.SignerName)
if name == "" {
continue
}
companyType := crmContactCompanyType(p.RefType)
if !crmContactCompanyExists(tenantID, companyType, p.RefID) {
continue
}
crmEnsureCompanyContact(tenantID, companyType, p.RefID, name, strings.TrimSpace(p.SignerPhone))
}
}
// 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 汇总口径一致):
// 清单单价为含税价,每行小计=数量×单价(不再叠加税率,税率仅作开票记录);
// 硬件部分=Σ硬件小计;软件部分=Σ软件小计;其他部分=Σ服务/开发/其他小计;
// 合同总金额=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本为不含税原值);
// 合同总利润=总金额-总成本。
// 归类口径:分类代码 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) != "" {
_ = json.Unmarshal([]byte(row.Products), &items)
}
var hardware, software, other, cost float64
for _, item := range items {
// 软件开发行:开发总成本由模块报价带出(默认 = 人天×统一人天单价,可手工改写),计入产品成本;
// 对外金额取销售单价(缺省时回退开发总成本 / 人天汇总,兼容历史数据);不计税率。
// 口径与前端 utils.js 的 lineAmount / lineCost / buildSummary 保持一致。
if fmt.Sprintf("%v", item["line_type"]) == "dev" {
qty := toFloat64(item["quantity"])
if qty <= 0 {
qty = 1
}
total := toFloat64(item["dev_total_price"])
if total <= 0 {
if tree, ok := item["dev_tree"].([]interface{}); ok {
total = contractTreeSellTotal(tree, toFloat64(item["dev_unit_price"]))
}
}
costPrice := toFloat64(item["cost_price"])
if costPrice <= 0 {
costPrice = total
}
price := toFloat64(item["price"])
if price <= 0 {
price = total
}
software = round2(software + round2(qty*price))
cost = round2(cost + round2(qty*costPrice))
continue
}
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 {
case cat == "1" || strings.Contains(cat, "硬件"):
hardware = round2(hardware + amount)
case cat == "2" || strings.Contains(cat, "软件"):
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)
}
// 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"])
}
// crmContractDuplicated 判断同租户内是否已存在「合同名称 + 签约方」均一致的合同。
//
// 判重口径:合同名称一致,且参与方(甲/乙/丙/丁)归一化签名一致。
// 因此同一名称下「我方作为甲方」与「我方作为乙方」的两份合同不算重复。
func crmContractDuplicated(tenantID, name, partiesJSON string) bool {
name = strings.TrimSpace(name)
if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
return false
}
sig := contractPartiesSignature(partiesJSON)
var rows []models.TenantCrmContract
_, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).
Filter("contract_name", name).
All(&rows, "id", "parties")
if err != nil {
return false
}
for i := range rows {
if contractPartiesSignature(rows[i].Parties) == sig {
return true
}
}
return false
}
// contractPartiesSignature 参与方归一化签名:按甲/乙/丙/丁固定顺序拼接「角色:主体类型|主体」。
// 主体优先取关联主体 ID(ref_id),无 ID(手工录入名称)时退化为名称(ref_name);
// 参与方为空或解析失败时返回空串,此时判重退化为「仅合同名称一致」。
func contractPartiesSignature(partiesJSON string) string {
if strings.TrimSpace(partiesJSON) == "" {
return ""
}
var parties []map[string]interface{}
if err := json.Unmarshal([]byte(partiesJSON), &parties); err != nil {
return ""
}
str := func(v interface{}) string {
if v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
byRole := make(map[string]string, len(parties))
for _, p := range parties {
role := str(p["role"])
if role == "" {
continue
}
subject := str(p["ref_id"])
// ref_id 为 0 / 空表示未关联主体库(本公司或手工录入),此时用名称兜底,
// 避免不同主体都归到 "0" 造成误判为重复
if subject == "" || subject == "0" {
subject = str(p["ref_name"])
}
byRole[role] = str(p["ref_type"]) + "|" + subject
}
var sb strings.Builder
for _, role := range []string{"party_a", "party_b", "party_c", "party_d"} {
if v, ok := byRole[role]; ok {
sb.WriteString(role)
sb.WriteString(":")
sb.WriteString(v)
sb.WriteString(";")
}
}
return sb.String()
}
// 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)
}
// normalizeOurRole 归一我方角色:0=我方不参与(如上中游项目中上游与中游主体之间的合同,
// 我方不是签约方),1=甲方 2=乙方 3=丙方 4=丁方,越界回退乙方。
func normalizeOurRole(v int8) int8 {
if v == 0 {
return 0
}
return pickInt8(v, 2, 4)
}
// 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
}