624 lines
21 KiB
Go
624 lines
21 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"`
|
|
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)
|
|
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)
|
|
}
|
|
}
|
|
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
|
|
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
|
|
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
|
|
row.Products = string(synced)
|
|
}
|
|
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
|
|
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
|
|
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
|
|
row.Products = string(synced)
|
|
}
|
|
}
|
|
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 汇总口径一致):
|
|
// 每行含税金额=数量×单价×(1+税率/100);
|
|
// 硬件部分=Σ硬件含税小计;软件部分=Σ软件含税小计;其他部分=Σ服务/开发/其他含税小计;
|
|
// 合同总金额(含税)=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本通常不含税,原值汇总);
|
|
// 合同总利润=总金额-总成本。
|
|
// 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他。
|
|
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"])
|
|
taxRate := toFloat64(item["tax_rate"])
|
|
amount := round2(qty * price * (1 + taxRate/100)) // 含税金额
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|