979 lines
36 KiB
Go
979 lines
36 KiB
Go
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
|
||
}
|