优化合同个管理和产品管理相关功能
This commit is contained in:
@@ -47,6 +47,7 @@ type contractPayload struct {
|
||||
SignDate string `json:"sign_date"`
|
||||
EffectiveDate string `json:"effective_date"`
|
||||
ExpireDate string `json:"expire_date"`
|
||||
TechDate string `json:"tech_date"`
|
||||
Parties json.RawMessage `json:"parties"`
|
||||
Products json.RawMessage `json:"products"`
|
||||
Step int8 `json:"step"`
|
||||
@@ -126,6 +127,7 @@ func (c *BackendCrmContractController) List() {
|
||||
}
|
||||
items := make([]contractResp, 0, len(list))
|
||||
for i := range list {
|
||||
c.syncContractExceptionStatus(&list[i])
|
||||
items = append(items, buildContractResp(&list[i]))
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
@@ -135,6 +137,8 @@ func (c *BackendCrmContractController) List() {
|
||||
|
||||
// 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 {
|
||||
@@ -142,13 +146,34 @@ func (c *BackendCrmContractController) Stats() {
|
||||
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 tenant_id = ? AND delete_time IS NULL AND status <> 3"
|
||||
"FROM " + table + " WHERE " + where
|
||||
var rows []orm.Params
|
||||
if _, err := models.Orm.Raw(raw, tenantID).Values(&rows); err != nil || len(rows) == 0 {
|
||||
pipelineOk(&c.Controller, contractSummary{TotalProfit: 0})
|
||||
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]
|
||||
@@ -160,6 +185,28 @@ func (c *BackendCrmContractController) Stats() {
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -179,6 +226,7 @@ func (c *BackendCrmContractController) Detail() {
|
||||
pipelineErr(&c.Controller, 404, 404, "合同未找到")
|
||||
return
|
||||
}
|
||||
c.syncContractExceptionStatus(&row)
|
||||
pipelineOk(&c.Controller, buildContractResp(&row))
|
||||
}
|
||||
|
||||
@@ -214,6 +262,7 @@ func (c *BackendCrmContractController) Create() {
|
||||
SignDate: parsePipelineDate(p.SignDate),
|
||||
EffectiveDate: parsePipelineDate(p.EffectiveDate),
|
||||
ExpireDate: parsePipelineDate(p.ExpireDate),
|
||||
TechDate: parsePipelineDate(p.TechDate),
|
||||
Status: pickInt8(p.Status, 1, 5),
|
||||
Step: pickInt8(p.Step, 1, 2),
|
||||
Remark: p.Remark,
|
||||
@@ -332,6 +381,7 @@ func (c *BackendCrmContractController) Update() {
|
||||
row.SignDate = parsePipelineDate(p.SignDate)
|
||||
row.EffectiveDate = parsePipelineDate(p.EffectiveDate)
|
||||
row.ExpireDate = parsePipelineDate(p.ExpireDate)
|
||||
row.TechDate = parsePipelineDate(p.TechDate)
|
||||
if p.Parties != nil {
|
||||
parties, err := normalizeContractJSON(p.Parties)
|
||||
if err != nil {
|
||||
@@ -532,6 +582,23 @@ func normalizeContractJSON(raw json.RawMessage) (string, error) {
|
||||
// 合同总金额(含税)=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本通常不含税,原值汇总);
|
||||
// 合同总利润=总金额-总成本。
|
||||
// 归类口径:分类代码 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) != "" {
|
||||
@@ -539,6 +606,14 @@ func applyContractAmounts(row *models.TenantCrmContract) {
|
||||
}
|
||||
var hardware, software, other, cost float64
|
||||
for _, item := range items {
|
||||
// 软件开发行:金额来自模块树汇总(统一人天单价×人天,不计税率/成本,与前端 buildSummary 一致)
|
||||
if fmt.Sprintf("%v", item["line_type"]) == "dev" {
|
||||
if tree, ok := item["dev_tree"].([]interface{}); ok {
|
||||
unitPrice := toFloat64(item["dev_unit_price"])
|
||||
software = round2(software + contractTreeSellTotal(tree, unitPrice))
|
||||
}
|
||||
continue
|
||||
}
|
||||
qty := toFloat64(item["quantity"])
|
||||
price := toFloat64(item["price"])
|
||||
costPrice := toFloat64(item["cost_price"])
|
||||
@@ -563,6 +638,42 @@ func applyContractAmounts(row *models.TenantCrmContract) {
|
||||
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"])
|
||||
}
|
||||
|
||||
// syncContractExceptionStatus 履约中合同状态对账:技术日期已过但回款未回完(已收 < 合同总额,
|
||||
// 允许 0.005 浮点容差)时,自动流转为「执行异常」(5)。无技术日期或不满足翻转条件则不动。
|
||||
// 在列表 / 详情读取时触发,也可在回款登记后调用,使状态及时反映。
|
||||
func (c *BackendCrmContractController) syncContractExceptionStatus(row *models.TenantCrmContract) {
|
||||
if row == nil || row.Status != 4 || row.TechDate == nil || row.ID == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if !row.TechDate.Before(now) {
|
||||
return
|
||||
}
|
||||
received := contractReceivedAmount(row.TenantID, row.ID)
|
||||
if row.TotalAmount > 0 && received+0.005 < row.TotalAmount {
|
||||
row.Status = 5
|
||||
row.UpdateTime = now
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmContract)).
|
||||
Filter("id", row.ID).Filter("tenant_id", row.TenantID).
|
||||
Update(orm.Params{"status": int8(5), "update_time": now})
|
||||
}
|
||||
}
|
||||
|
||||
// genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。
|
||||
func genContractNo(tenantID string) string {
|
||||
for i := 0; i < 5; i++ {
|
||||
|
||||
Reference in New Issue
Block a user