批量更新功能

This commit is contained in:
2026-09-14 16:15:44 +08:00
parent 6f106e43af
commit a26c98b92b
79 changed files with 4529 additions and 724 deletions
+137 -26
View File
@@ -35,23 +35,25 @@ type contractSummary struct {
// 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"`
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 由金额字段组装。
@@ -138,7 +140,58 @@ func (c *BackendCrmContractController) List() {
total, _ := qs.Count()
var list []models.TenantCrmContract
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
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...)
}
}
}
items := make([]contractResp, 0, len(list))
for i := range list {
@@ -153,6 +206,7 @@ func (c *BackendCrmContractController) List() {
// 全租户合同统计(排除已作废),供列表页顶部汇总卡片使用。
// 支持按周期筛选:period=month/quarter/year(当期)或 custom(start_date~end_date),
// 按合同有效期 [生效日期, 结束日期] 与所选周期重叠统计。
// 「合同总利润」仅统计非甲方(收款方)合同:我方为甲方的合同是采购付款,只有支出、不存在利润。
func (c *BackendCrmContractController) Stats() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
@@ -168,7 +222,8 @@ func (c *BackendCrmContractController) Stats() {
}
table := new(models.TenantCrmContract).TableName()
where := "tenant_id = ? AND delete_time IS NULL AND status <> 3"
// 我方不参与的合同(our_role=0,如上中游项目的链条合同)不属于我方经营合同,不计入经营统计
where := "tenant_id = ? AND delete_time IS NULL AND status <> 3 AND our_role <> 0"
args := []interface{}{tenantID}
// 按合同有效期 [生效日期, 结束日期] 与所选周期 [start, end] 重叠来统计:
// 合同生效日 <= 周期末 且 合同结束日 >= 周期初;日期为空视为不限制对应边界。
@@ -180,8 +235,10 @@ func (c *BackendCrmContractController) Stats() {
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 " +
"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 {
@@ -270,11 +327,15 @@ func (c *BackendCrmContractController) Create() {
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: pickInt8(p.OurRole, 2, 4),
OurRole: normalizeOurRole(ourRole),
PartyCount: pickInt8(p.PartyCount, 2, 4),
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
@@ -338,6 +399,8 @@ func (c *BackendCrmContractController) Create() {
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})
}
@@ -386,8 +449,9 @@ func (c *BackendCrmContractController) Update() {
if strings.TrimSpace(p.ContractCategory) != "" {
row.ContractCategory = strings.TrimSpace(p.ContractCategory)
}
if p.OurRole != 0 {
row.OurRole = p.OurRole
if p.OurRole != nil {
// 0=我方不参与也是合法值,用指针判断避免误吞
row.OurRole = normalizeOurRole(*p.OurRole)
}
if p.PartyCount != 0 {
row.PartyCount = p.PartyCount
@@ -451,6 +515,8 @@ func (c *BackendCrmContractController) Update() {
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})
}
@@ -496,7 +562,7 @@ func (c *BackendCrmContractController) Delete() {
}
// ChangeStatus POST /backend/crm/contract/:id/status
// 合同状态流转:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常。
// 合同状态流转:1=草稿 2=已完成 3=已作废 4=履约中 5=异常。
// 向导创建的合同默认为草稿,签订 / 履约等状态在列表中手动流转;各状态间可互切。
func (c *BackendCrmContractController) ChangeStatus() {
claims, err := pipelineClaims(&c.Controller)
@@ -517,7 +583,7 @@ func (c *BackendCrmContractController) ChangeStatus() {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
// 合法目标状态:2已完成 / 3已作废 / 4履约中 / 5执行异常
// 合法目标状态:2已完成 / 3已作废 / 4履约中 / 5异常
if p.Status < 2 || p.Status > 5 {
pipelineErr(&c.Controller, 400, 400, "无效的状态值")
return
@@ -561,7 +627,7 @@ func contractStatusName(s int8) string {
case 4:
return "履约中"
case 5:
return "执行异常"
return "异常"
default:
return "未知"
}
@@ -600,6 +666,42 @@ func buildContractResp(row *models.TenantCrmContract) contractResp {
}
}
// 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
@@ -806,6 +908,15 @@ func genContractNo(tenantID string) string {
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 {