Files
yunzerwebsiteallinone/go/controllers/backend_crm_contract.go
T
2026-09-13 22:31:42 +08:00

853 lines
30 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"`
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"))
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 {
_, _ = 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
// 全租户合同统计(排除已作废),供列表页顶部汇总卡片使用。
// 支持按周期筛选: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()
where := "tenant_id = ? AND delete_time IS NULL AND status <> 3"
args := []interface{}{tenantID}
// 按合同有效期 [生效日期, 结束日期] 与所选周期 [start, end] 重叠来统计:
// 合同生效日 <= 周期末 且 合同结束日 >= 周期初;日期为空视为不限制对应边界。
if start != "" {
where += " AND (expire_date IS NULL OR expire_date >= ?)"
args = append(args, start)
}
if end != "" {
where += " AND (effective_date IS NULL OR effective_date <= ?)"
args = append(args, end)
}
raw := "SELECT COUNT(*) AS total, IFNULL(SUM(total_amount),0) AS total_amount, " +
"IFNULL(SUM(total_cost),0) AS total_cost, IFNULL(SUM(total_profit),0) AS total_profit " +
"FROM " + table + " WHERE " + 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
}
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
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行)
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
}
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 != 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
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行)
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
}
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,
},
}
}
// 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)
}
// 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
}