批量更新功能

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
+16
View File
@@ -63,6 +63,8 @@ type erpContactDTO struct {
ContactName string `json:"contact_name"`
Gender int8 `json:"gender"`
Phone string `json:"phone"`
// 手机号可登记多个(有的人有多个号码):JSON 数组,最多 5 个;phone 保留为第一个
Mobiles string `json:"mobiles"`
WorkPhone string `json:"work_phone"`
Email string `json:"email"`
Wechat string `json:"wechat"`
@@ -188,6 +190,11 @@ func (c *BackendErpContactController) Create() {
contactType, _ := c.contactGetIntValue(body, "contact_type")
gender, _ := c.contactGetIntValue(body, "gender")
phone, _ := c.contactGetStringValue(body, "phone")
mobilesJSON, _ := c.contactGetStringValue(body, "mobiles")
// 多手机号:优先使用 mobiles 字段;未传手机号时从 mobiles 取第一个回填 phone(兼容旧调用方)
if strings.TrimSpace(phone) == "" {
phone = firstMobile(strings.TrimSpace(mobilesJSON))
}
workPhone, _ := c.contactGetStringValue(body, "work_phone")
email, _ := c.contactGetStringValue(body, "email")
wechat, _ := c.contactGetStringValue(body, "wechat")
@@ -211,6 +218,7 @@ func (c *BackendErpContactController) Create() {
ContactName: contactName,
Gender: int8(gender),
Phone: contactStrPtr(phone),
Mobiles: contactStrPtr(mobilesJSON),
WorkPhone: contactStrPtr(workPhone),
Email: contactStrPtr(email),
Wechat: contactStrPtr(wechat),
@@ -276,6 +284,13 @@ func (c *BackendErpContactController) Update() {
if v, has := c.contactGetStringValue(body, "phone"); has {
update["phone"] = contactNullableString(v)
}
if v, has := c.contactGetStringValue(body, "mobiles"); has {
update["mobiles"] = contactNullableString(v)
// 多手机号下,phone 始终为第一个号码,保持两者一致
if p := firstMobile(strings.TrimSpace(v)); p != "" {
update["phone"] = contactNullableString(p)
}
}
if v, has := c.contactGetStringValue(body, "work_phone"); has {
update["work_phone"] = contactNullableString(v)
}
@@ -559,6 +574,7 @@ func (c *BackendErpContactController) contactDTO(row models.BackendErpContact) e
ContactName: row.ContactName,
Gender: row.Gender,
Phone: contactDerefString(row.Phone),
Mobiles: contactDerefString(row.Mobiles),
WorkPhone: contactDerefString(row.WorkPhone),
Email: contactDerefString(row.Email),
Wechat: contactDerefString(row.Wechat),
+35 -2
View File
@@ -2,6 +2,7 @@ package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
@@ -29,6 +30,8 @@ type cluePayload struct {
Amount float64 `json:"amount"` // 预计金额
NextContactTime string `json:"next_contact_time"`
ClueLevel string `json:"clue_level"`
// 线索状态:1跟进中 / 2已转化 / 3已废弃(编辑时可调整;新增固定为跟进中)
Status string `json:"status"`
ContactPerson string `json:"contact_person"`
ContactPosition string `json:"contact_position"`
ContactPhone string `json:"contact_phone"`
@@ -89,8 +92,9 @@ func (c *BackendCrmClueController) List() {
total, _ := qs.Count()
var list []models.TenantCrmClue
if total > 0 {
// 未转化的排前面,已转化的(locked=1)沉到列表下方
_, _ = qs.OrderBy("locked", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
// 列表排列:跟进中(1) → 已转化(2) → 已废弃(3),同状态下按新→旧。
// 状态值本身即为期望的展示顺序,按 status 升序即可(排序在数据库侧完成,翻页后整体顺序一致)。
_, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize,
@@ -262,16 +266,45 @@ func (c *BackendCrmClueController) Update() {
clue.ContactQQ = strings.TrimSpace(p.ContactQQ)
clue.Address = strings.TrimSpace(p.Address)
clue.Remark = p.Remark
// 线索状态:编辑时可直接管理(1跟进中 / 2已转化 / 3已废弃)。
// 标为「已转化」时与转化流程保持一致:同时锁定(locked=1),列表中沉底且不能再编辑 / 转化;
// 改回跟进中 / 已废弃则解除锁定(历史数据同样兜底)。
prevStatus := clue.Status
if s := strings.TrimSpace(p.Status); s != "" {
if v, err := strconv.Atoi(s); err == nil && v >= 1 && v <= 3 {
clue.Status = int8(v)
clue.Locked = 0
if clue.Status == 2 {
clue.Locked = 1
}
}
}
clue.UpdateTime = time.Now()
if _, err := models.Orm.Update(&clue); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
if clue.Status != prevStatus {
crmWriteLog(clue.TenantID, 1, clue.ID, "status",
fmt.Sprintf("线索状态:%s → %s", clueStatusName(prevStatus), clueStatusName(clue.Status)), claims)
}
crmWriteLog(clue.TenantID, 1, clue.ID, "update", "更新线索:"+clue.ClueName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID})
}
// clueStatusName 线索状态文案(用于操作日志):1跟进中 / 2已转化 / 3已废弃
func clueStatusName(s int8) string {
switch s {
case 2:
return "已转化"
case 3:
return "已废弃"
default:
return "跟进中"
}
}
// Delete DELETE /backend/crm/clue/:id
func (c *BackendCrmClueController) Delete() {
claims, err := pipelineClaims(&c.Controller)
+216 -6
View File
@@ -69,6 +69,99 @@ func buildMobiles(mobile string) string {
return string(b)
}
// buildMobilesFromList 将多个手机号去空、去重后转为 mobiles 的 JSON 数组字符串(最多 5 个,与 ERP 通讯录一致)
func buildMobilesFromList(mobiles []string) string {
cleaned := make([]string, 0, len(mobiles))
seen := map[string]bool{}
for _, m := range mobiles {
m = strings.TrimSpace(m)
if m == "" || seen[m] {
continue
}
seen[m] = true
cleaned = append(cleaned, m)
if len(cleaned) >= 5 {
break
}
}
if len(cleaned) == 0 {
return ""
}
b, err := json.Marshal(cleaned)
if err != nil {
return ""
}
return string(b)
}
// normalizeMobiles 归一请求中的手机号:
// 优先取 mobiles(数组,CRM 联系人支持登记多个手机号,如一人多号),
// 缺省时回退单个 mobile(兼容线索转化 / 合同签约人自动建档等旧调用方)。
func normalizeMobiles(mobiles []string, mobile string) string {
if len(mobiles) == 0 {
return buildMobiles(mobile)
}
return buildMobilesFromList(mobiles)
}
// splitMobiles 把手机号文本(JSON 数组 / 逗号等分隔符拼接)拆成号码列表(去空)
func splitMobiles(mobiles string) []string {
mobiles = strings.TrimSpace(mobiles)
if mobiles == "" {
return nil
}
var arr []string
if err := json.Unmarshal([]byte(mobiles), &arr); err == nil {
out := make([]string, 0, len(arr))
for _, m := range arr {
if m = strings.TrimSpace(m); m != "" {
out = append(out, m)
}
}
return out
}
parts := strings.FieldsFunc(mobiles, func(r rune) bool {
switch r {
case ',', ',', '、', ';', ';', ' ', '/', '|':
return true
}
return false
})
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// mobilesFromPayload 解析请求体中的手机号字段,兼容多种入参形式:
// - JSON 数组:["138…","139…"](新前端)
// - JSON 字符串:'["138…"]' 或 '138…,139…'(旧调用方)
// - 缺省:回退单个 mobile / phone 字段
//
// 统一返回 mobiles 的 JSON 数组字符串(去空去重,最多 5 个)。
func mobilesFromPayload(raw json.RawMessage, fallback string) string {
if len(raw) > 0 {
text := strings.TrimSpace(string(raw))
if text != "" && text != "null" {
var arr []string
if err := json.Unmarshal(raw, &arr); err == nil {
return buildMobilesFromList(arr)
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if strings.TrimSpace(s) == "" {
return ""
}
return buildMobilesFromList(splitMobiles(s))
}
}
}
return buildMobiles(fallback)
}
// firstMobile 从 mobiles(JSON 数组或普通字符串)中取出第一个手机号
func firstMobile(mobiles string) string {
mobiles = strings.TrimSpace(mobiles)
@@ -221,6 +314,118 @@ func clearOtherPrimary(tenantID, companyType string, companyID, excludeID uint64
_, _ = qs.Update(map[string]interface{}{"is_primary": 0})
}
// isMobileNumber 粗略识别手机号(11 位、1 开头,忽略空格 / 横线等分隔符);
// 非手机号按座机写入 phone 字段,保证联系人资料里手机号 / 座机分列正确。
func isMobileNumber(phone string) bool {
digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, phone)
return len(digits) == 11 && digits[0] == '1'
}
// crmContactCompanyExists 关联企业(客户 / 供应商)在当前租户下是否存在且未删除
func crmContactCompanyExists(tenantID, companyType string, companyID uint64) bool {
if models.Orm == nil || companyID == 0 {
return false
}
if companyType == "supplier" {
cnt, err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
Filter("id", companyID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).Count()
return err == nil && cnt > 0
}
cnt, err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
Filter("id", companyID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).Count()
return err == nil && cnt > 0
}
// contactPhoneMatched 联系人是否已登记该电话(手机号数组或座机任一命中)
func contactPhoneMatched(row models.ErpCompanyContact, phone string) bool {
if strings.TrimSpace(row.Phone) == phone {
return true
}
var mobiles []string
if err := json.Unmarshal([]byte(strings.TrimSpace(row.Mobiles)), &mobiles); err == nil {
for _, m := range mobiles {
if strings.TrimSpace(m) == phone {
return true
}
}
}
return false
}
// crmEnsureCompanyContact 确保企业通讯录(联系人)中已有该对接人:
// - 同名或同电话已存在时不再重复建档,仅在原联系人缺电话时补全;
// - 不存在时按「姓名 + 电话」建档,其余资料留空,后续可在联系人管理中补全;
// - 该企业还没有任何联系人时,默认把首个联系人设为主联系人。
func crmEnsureCompanyContact(tenantID, companyType string, companyID uint64, name, phone string) {
if models.Orm == nil || companyID == 0 {
return
}
name = strings.TrimSpace(name)
phone = strings.TrimSpace(phone)
if name == "" {
return
}
var rows []models.ErpCompanyContact
if _, err := models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("tenant_id", tenantID).
Filter("company_type", companyType).
Filter("company_id", companyID).
Filter("delete_time__isnull", true).
All(&rows); err != nil && err != orm.ErrNoRows {
return
}
for i := range rows {
row := rows[i]
sameName := strings.EqualFold(strings.TrimSpace(row.Name), name)
samePhone := phone != "" && contactPhoneMatched(row, phone)
if !sameName && !samePhone {
continue
}
// 已存在:仅在原记录没有电话时补全,不覆盖其他已填资料
if phone != "" && strings.TrimSpace(row.Mobiles) == "" && strings.TrimSpace(row.Phone) == "" {
up := map[string]interface{}{"update_time": time.Now()}
if isMobileNumber(phone) {
up["mobiles"] = buildMobiles(phone)
} else {
up["phone"] = phone
}
_, _ = models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("id", row.ID).Update(up)
}
return
}
now := time.Now()
contact := models.ErpCompanyContact{
TenantID: tenantID,
CompanyType: companyType,
CompanyID: companyID,
Name: name,
Status: 1,
Remark: "由合同签约信息自动写入,可在联系人中补全其他资料",
CreateTime: now,
UpdateTime: now,
}
if len(rows) == 0 {
contact.IsPrimary = 1
}
if phone != "" {
if isMobileNumber(phone) {
contact.Mobiles = buildMobiles(phone)
} else {
contact.Phone = phone
}
}
_, _ = models.Orm.Insert(&contact)
}
// List GET /backend/crm/contact/list?related_type=1&related_id=7&keyword=&is_primary=
func (c *BackendCrmContactController) List() {
claims, err := c.contactClaims()
@@ -259,6 +464,7 @@ func (c *BackendCrmContactController) List() {
if keyword != "" {
kw := orm.NewCondition().
Or("name__contains", keyword).
Or("mobiles__contains", keyword).
Or("phone__contains", keyword).
Or("email__contains", keyword)
cond = cond.AndCond(kw)
@@ -301,7 +507,9 @@ func (c *BackendCrmContactController) Add() {
ContactName string `json:"contact_name"`
Gender int8 `json:"gender"`
Mobile string `json:"mobile"`
Phone string `json:"phone"`
// 手机号可登记多个(有的人有多个号码):优先使用,缺省时回退单个 mobile
Mobiles []string `json:"mobiles"`
Phone string `json:"phone"`
Email string `json:"email"`
Wechat string `json:"wechat"`
QQ string `json:"qq"`
@@ -335,8 +543,8 @@ func (c *BackendCrmContactController) Add() {
CompanyID: p.RelatedID,
Name: strings.TrimSpace(p.ContactName),
Gender: p.Gender,
Phone: strings.TrimSpace(p.Phone), // 座机
Mobiles: buildMobiles(p.Mobile), // 手机号(JSON数组)
Phone: strings.TrimSpace(p.Phone), // 座机
Mobiles: normalizeMobiles(p.Mobiles, p.Mobile), // 手机号(支持多个,JSON数组)
Email: strings.TrimSpace(p.Email),
Wechat: strings.TrimSpace(p.Wechat),
QQ: strings.TrimSpace(p.QQ),
@@ -381,7 +589,9 @@ func (c *BackendCrmContactController) Edit() {
ContactName string `json:"contact_name"`
Gender int8 `json:"gender"`
Mobile string `json:"mobile"`
Phone string `json:"phone"`
// 手机号可登记多个(有的人有多个号码):优先使用,缺省时回退单个 mobile
Mobiles []string `json:"mobiles"`
Phone string `json:"phone"`
Email string `json:"email"`
Wechat string `json:"wechat"`
QQ string `json:"qq"`
@@ -430,8 +640,8 @@ func (c *BackendCrmContactController) Edit() {
contact.CompanyID = companyID
contact.Name = strings.TrimSpace(p.ContactName)
contact.Gender = p.Gender
contact.Phone = strings.TrimSpace(p.Phone) // 座机
contact.Mobiles = buildMobiles(p.Mobile) // 手机号(JSON数组)
contact.Phone = strings.TrimSpace(p.Phone) // 座机
contact.Mobiles = normalizeMobiles(p.Mobiles, p.Mobile) // 手机号(支持多个,JSON数组)
contact.Email = strings.TrimSpace(p.Email)
contact.Wechat = strings.TrimSpace(p.Wechat)
contact.QQ = strings.TrimSpace(p.QQ)
+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 {
+363
View File
@@ -0,0 +1,363 @@
package controllers
import (
"fmt"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
)
/*
客户 / 供应商互转(复制一套档案):
同一家单位可能既是客户又是供应商(先做客户后成为供应商,或反之),因此这里做「复制」而不是搬迁:
- 原记录保留(客户仍是客户,供应商仍是供应商),两边各自独立维护、互不影响;
- 企业资料整体复制:工商信息、注册 / 实缴资本、成立日期、行政区划、企业类型、纳税人资质、
经营范围、地址、合作期限(注册 / 到期时间)、开票资料、银行信息、备注;
- 联系人(yz_backend_contact_company)一并复制,按「姓名 + 手机号」去重,避免重复建档;
- 等级(核心 / 重要 / 普通 / 潜在)两边语义一致,直接沿用;
- 类型(客户类型:企业 / 政府 / 国企 / 教育 / 个人 与 供应商类型:原材料 / 设备 / 服务 / 其他)
语义不同,按目标库默认值建档(客户→供应商 为「其他」、供应商→客户 为「企业」),转化后可再调整;
- 目标库已存在同名企业时不再重复建档,只把联系人补同步过去,避免同库出现两条同名档案。
*/
// findSameNameSupplier 查找租户内同名且未删除的供应商,未找到返回 0
func findSameNameSupplier(tenantID, name string) uint64 {
name = strings.TrimSpace(name)
if models.Orm == nil || name == "" {
return 0
}
var row models.TenantCrmSupplier
if err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
Filter("tenant_id", tenantID).
Filter("supplier_name", name).
Filter("delete_time__isnull", true).
One(&row); err != nil {
return 0
}
return row.ID
}
// findSameNameCustomer 查找租户内同名且未删除的客户,未找到返回 0
func findSameNameCustomer(tenantID, name string) uint64 {
name = strings.TrimSpace(name)
if models.Orm == nil || name == "" {
return 0
}
var row models.TenantCrmCustomer
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
Filter("tenant_id", tenantID).
Filter("customer_name", name).
Filter("delete_time__isnull", true).
One(&row); err != nil {
return 0
}
return row.ID
}
// collectCustomerNames / collectSupplierNames 提取名称集合(去重、去空),用于批量判断双重身份
func collectCustomerNames(list []models.TenantCrmCustomer) []string {
names := make([]string, 0, len(list))
for _, r := range list {
if n := strings.TrimSpace(r.CustomerName); n != "" {
names = append(names, n)
}
}
return names
}
func collectSupplierNames(list []models.TenantCrmSupplier) []string {
names := make([]string, 0, len(list))
for _, r := range list {
if n := strings.TrimSpace(r.SupplierName); n != "" {
names = append(names, n)
}
}
return names
}
// partyNameExists 批量判断名称是否已存在于指定库(kind:customer / supplier),
// 供列表标记「同时也是客户 / 供应商」,避免用户重复转化。返回 map 的 key 已做 trim。
func partyNameExists(tenantID, kind string, names []string) map[string]bool {
exists := map[string]bool{}
if models.Orm == nil || len(names) == 0 {
return exists
}
if kind == "supplier" {
var rows []models.TenantCrmSupplier
if _, err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
Filter("tenant_id", tenantID).
Filter("supplier_name__in", names).
Filter("delete_time__isnull", true).
All(&rows); err == nil {
for _, r := range rows {
exists[strings.TrimSpace(r.SupplierName)] = true
}
}
return exists
}
var rows []models.TenantCrmCustomer
if _, err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
Filter("tenant_id", tenantID).
Filter("customer_name__in", names).
Filter("delete_time__isnull", true).
All(&rows); err == nil {
for _, r := range rows {
exists[strings.TrimSpace(r.CustomerName)] = true
}
}
return exists
}
// customerListRow 客户列表行:附带「同时也是供应商」标记,便于识别同一家单位的双重身份
type customerListRow struct {
models.TenantCrmCustomer
AlsoSupplier bool `json:"also_supplier"`
}
// supplierListRow 供应商列表行:附带「同时也是客户」标记
type supplierListRow struct {
models.TenantCrmSupplier
AlsoCustomer bool `json:"also_customer"`
}
// partyConvertRemark 转化后的备注:保留原备注并追加转化来源,便于溯源
func partyConvertRemark(origin, fromLabel, name string) string {
tag := fmt.Sprintf("由%s「%s」转化而来", fromLabel, name)
origin = strings.TrimSpace(origin)
if origin == "" {
return tag
}
return origin + "\n" + tag
}
// copyCompanyContacts 把来源企业(客户 / 供应商)的联系人复制到目标企业:
// - 去重键「姓名 + 手机号」:命中时只补齐目标缺失字段,不覆盖已有资料;
// - 未命中时新档;目标企业已有联系人时不再设为主联系人,避免出现多个主联系人。
//
// 返回 (新增数, 合并数)。
func copyCompanyContacts(tenantID, fromType string, fromID uint64, toType string, toID uint64) (int, int) {
if models.Orm == nil || fromID == 0 || toID == 0 {
return 0, 0
}
var src []models.ErpCompanyContact
if _, err := models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("tenant_id", tenantID).
Filter("company_type", fromType).
Filter("company_id", fromID).
Filter("delete_time__isnull", true).
All(&src); err != nil && err != orm.ErrNoRows {
return 0, 0
}
if len(src) == 0 {
return 0, 0
}
var dst []models.ErpCompanyContact
_, _ = models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("tenant_id", tenantID).
Filter("company_type", toType).
Filter("company_id", toID).
Filter("delete_time__isnull", true).
All(&dst)
now := time.Now()
inserted, merged := 0, 0
for _, s := range src {
srcName := strings.TrimSpace(s.Name)
srcMobile := firstMobile(s.Mobiles)
hit := -1
for i := range dst {
if !strings.EqualFold(strings.TrimSpace(dst[i].Name), srcName) {
continue
}
// 有手机号时必须手机号也匹配,避免同名不同人被判为同一人
if srcMobile != "" && !contactPhoneMatched(dst[i], srcMobile) {
continue
}
hit = i
break
}
if hit >= 0 {
d := dst[hit]
up := map[string]interface{}{}
if strings.TrimSpace(d.Mobiles) == "" && strings.TrimSpace(s.Mobiles) != "" {
up["mobiles"] = s.Mobiles
}
if strings.TrimSpace(d.Phone) == "" && strings.TrimSpace(s.Phone) != "" {
up["phone"] = s.Phone
}
if strings.TrimSpace(d.Email) == "" && strings.TrimSpace(s.Email) != "" {
up["email"] = s.Email
}
if strings.TrimSpace(d.Wechat) == "" && strings.TrimSpace(s.Wechat) != "" {
up["wechat"] = s.Wechat
}
if strings.TrimSpace(d.QQ) == "" && strings.TrimSpace(s.QQ) != "" {
up["qq"] = s.QQ
}
if strings.TrimSpace(d.Dingtalk) == "" && strings.TrimSpace(s.Dingtalk) != "" {
up["dingtalk"] = s.Dingtalk
}
if strings.TrimSpace(d.Department) == "" && strings.TrimSpace(s.Department) != "" {
up["department"] = s.Department
}
if strings.TrimSpace(d.Position) == "" && strings.TrimSpace(s.Position) != "" {
up["position"] = s.Position
}
if strings.TrimSpace(d.HomeAddress) == "" && strings.TrimSpace(s.HomeAddress) != "" {
up["home_address"] = s.HomeAddress
}
if len(up) == 0 {
continue
}
up["update_time"] = now
if _, err := models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("id", d.ID).Update(up); err == nil {
merged++
}
continue
}
row := s
row.ID = 0
row.CompanyType = toType
row.CompanyID = toID
row.CreateTime = now
row.UpdateTime = now
if len(dst) > 0 {
row.IsPrimary = 0
}
if _, err := models.Orm.Insert(&row); err == nil {
inserted++
}
}
return inserted, merged
}
// convertCustomerToSupplier 客户 → 供应商:复制客户档案与联系人到供应商库。
// 返回 (供应商库是否已存在同名企业, 供应商ID, 新增联系人, 合并联系人)。
func convertCustomerToSupplier(tenantID string, src *models.TenantCrmCustomer, claims *jwtutil.Claims) (bool, uint64, int, int, error) {
if models.Orm == nil {
return false, 0, 0, 0, fmt.Errorf("数据库未初始化")
}
if src == nil || src.ID == 0 {
return false, 0, 0, 0, fmt.Errorf("客户未找到")
}
name := strings.TrimSpace(src.CustomerName)
if name == "" {
return false, 0, 0, 0, fmt.Errorf("客户名称为空,无法转化")
}
// 供应商库已有同名企业:不重复建档,只把联系人补同步过去
if id := findSameNameSupplier(tenantID, name); id > 0 {
inserted, merged := copyCompanyContacts(tenantID, "customer", src.ID, "supplier", id)
return true, id, inserted, merged, nil
}
now := time.Now()
supplier := models.TenantCrmSupplier{
TenantID: tenantID,
SupplierName: name,
SupplierType: "4", // 客户类型与供应商类型语义不同,默认「其他」,建档后可调整
ContactPerson: src.ContactPerson,
ContactPhone: src.ContactPhone,
ContactEmail: src.ContactEmail,
SupplierLevel: firstNonEmpty(src.CustomerLevel, "3"), // 等级语义一致,直接沿用
Industry: src.Industry,
RegisteredCapital: src.RegisteredCapital,
PaidCapital: src.PaidCapital,
EstablishDate: src.EstablishDate,
AdministrativeDivision: src.AdministrativeDivision,
EnterpriseType: src.EnterpriseType,
TaxpayerQualification: src.TaxpayerQualification,
BusinessScope: src.BusinessScope,
Address: src.Address,
RegisterTime: src.RegisterTime,
ExpireTime: src.ExpireTime,
Status: firstNonEmpty(src.Status, "1"),
IsDraft: src.IsDraft,
Remark: partyConvertRemark(src.Remark, "客户", name),
InvoiceTitle: src.InvoiceTitle,
TaxNumber: src.TaxNumber,
BankName: src.BankName,
BankAccount: src.BankAccount,
RegisteredAddress: src.RegisteredAddress,
RegisteredPhone: src.RegisteredPhone,
CreateUserID: fmt.Sprintf("%d", claims.UserID),
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&supplier)
if err != nil {
return false, 0, 0, 0, fmt.Errorf("生成供应商失败: " + err.Error())
}
inserted, merged := copyCompanyContacts(tenantID, "customer", src.ID, "supplier", uint64(id))
return false, uint64(id), inserted, merged, nil
}
// convertSupplierToCustomer 供应商 → 客户:复制供应商档案与联系人到客户库。
// 返回 (客户库是否已存在同名企业, 客户ID, 新增联系人, 合并联系人)。
func convertSupplierToCustomer(tenantID string, src *models.TenantCrmSupplier, claims *jwtutil.Claims) (bool, uint64, int, int, error) {
if models.Orm == nil {
return false, 0, 0, 0, fmt.Errorf("数据库未初始化")
}
if src == nil || src.ID == 0 {
return false, 0, 0, 0, fmt.Errorf("供应商未找到")
}
name := strings.TrimSpace(src.SupplierName)
if name == "" {
return false, 0, 0, 0, fmt.Errorf("供应商名称为空,无法转化")
}
// 客户库已有同名企业:不重复建档,只把联系人补同步过去
if id := findSameNameCustomer(tenantID, name); id > 0 {
inserted, merged := copyCompanyContacts(tenantID, "supplier", src.ID, "customer", id)
return true, id, inserted, merged, nil
}
now := time.Now()
userID := fmt.Sprintf("%d", claims.UserID)
customer := models.TenantCrmCustomer{
TenantID: tenantID,
CustomerName: name,
CustomerType: "1", // 供应商类型与客户类型语义不同,默认「企业」,建档后可调整
ContactPerson: src.ContactPerson,
ContactPhone: src.ContactPhone,
ContactEmail: src.ContactEmail,
CustomerLevel: firstNonEmpty(src.SupplierLevel, "3"), // 等级语义一致,直接沿用
Industry: src.Industry,
RegisteredCapital: src.RegisteredCapital,
PaidCapital: src.PaidCapital,
EstablishDate: src.EstablishDate,
AdministrativeDivision: src.AdministrativeDivision,
EnterpriseType: src.EnterpriseType,
TaxpayerQualification: src.TaxpayerQualification,
BusinessScope: src.BusinessScope,
Address: src.Address,
RegisterTime: src.RegisterTime,
ExpireTime: src.ExpireTime,
Status: firstNonEmpty(src.Status, "1"),
IsDraft: src.IsDraft,
Remark: partyConvertRemark(src.Remark, "供应商", name),
InvoiceTitle: src.InvoiceTitle,
TaxNumber: src.TaxNumber,
BankName: src.BankName,
BankAccount: src.BankAccount,
RegisteredAddress: src.RegisteredAddress,
RegisteredPhone: src.RegisteredPhone,
InPool: 0,
OwnerUserID: userID,
OwnerUserName: resolveUserName(claims),
CreateUserID: userID,
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&customer)
if err != nil {
return false, 0, 0, 0, fmt.Errorf("生成客户失败: " + err.Error())
}
inserted, merged := copyCompanyContacts(tenantID, "supplier", src.ID, "customer", uint64(id))
return false, uint64(id), inserted, merged, nil
}
+36
View File
@@ -9,6 +9,7 @@ import (
"time"
"server/models"
"server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
@@ -269,6 +270,10 @@ func (c *BackendCrmPaybackController) Create() {
return
}
crmWriteLog(tenantID, 4, row.ID, "create", "创建回款计划:"+row.ContractName, claims)
// 回款逾期联动:新建的计划可能已含逾期节点,合同 / 项目状态即时生效
if row.ContractID != nil {
services.SyncPaybackOverdueForContract(tenantID, *row.ContractID)
}
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
@@ -351,6 +356,10 @@ func (c *BackendCrmPaybackController) Update() {
// 已回款金额一律以「回款记录」为准重新汇总:
// 明细里不承载实收数据,若此处按明细重算,编辑计划(如调整回款日期)会把已回款冲成 0。
refreshPlanReceived(tenantID, row.ID)
// 回款逾期联动:调整计划日期 / 金额后,合同 / 项目状态即时生效
if row.ContractID != nil {
services.SyncPaybackOverdueForContract(tenantID, *row.ContractID)
}
crmWriteLog(tenantID, 4, row.ID, "update", "更新回款计划:"+row.ContractName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
@@ -539,6 +548,10 @@ func (c *BackendCrmPaybackController) Receive() {
return
}
crmWriteLog(tenantID, 4, row.ID, "receive", fmt.Sprintf("回款登记:%s 第 %d 期 %s 元", row.ContractName, p.Seq, formatAmount(p.ReceivedAmount)), claims)
// 回款逾期联动:登记金额变化后,合同 / 项目状态即时生效
if row.ContractID != nil {
services.SyncPaybackOverdueForContract(tenantID, *row.ContractID)
}
pipelineOk(&c.Controller, buildPaybackResp(&row))
}
@@ -759,6 +772,7 @@ func (c *BackendCrmPaybackController) CreateRecord() {
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
PaybackID uint64 `json:"payback_id"`
ItemSeq int `json:"item_seq"` // 对应计划明细序号(1-based),0=未指定(按整单登记)
Amount float64 `json:"amount"`
ReceivedDate string `json:"received_date"`
PayMethod int8 `json:"pay_method"`
@@ -791,6 +805,22 @@ func (c *BackendCrmPaybackController) CreateRecord() {
if payMethod <= 0 {
payMethod = plan.PayMethod
}
// 进度节点:指定 item_seq 时校验其在计划明细中存在,并留存节点名称快照
itemSeq := 0
itemName := ""
if p.ItemSeq > 0 {
for _, it := range parsePaybackItems(plan.Items) {
if it.Seq == p.ItemSeq {
itemSeq = it.Seq
itemName = it.Name
break
}
}
if itemSeq == 0 {
pipelineErr(&c.Controller, 400, 400, "计划明细节点不存在")
return
}
}
var contractID *uint64
if plan.ContractID != nil {
cid := *plan.ContractID
@@ -801,6 +831,8 @@ func (c *BackendCrmPaybackController) CreateRecord() {
TenantID: tenantID,
ContractID: contractID,
PaybackID: plan.ID,
ItemSeq: itemSeq,
ItemName: itemName,
Amount: round2(p.Amount),
ReceivedDate: strings.TrimSpace(p.ReceivedDate),
PayMethod: payMethod,
@@ -863,6 +895,10 @@ func (c *BackendCrmPaybackController) DeleteRecord() {
return
}
refreshPlanReceived(tenantID, row.PaybackID)
// 回款逾期联动:删除回款记录后可能转为逾期,合同 / 项目状态即时生效
if row.ContractID != nil {
services.SyncPaybackOverdueForContract(tenantID, *row.ContractID)
}
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
+34 -7
View File
@@ -161,6 +161,30 @@ func mobileInMobiles(mobile, mobiles string) bool {
return strings.Contains(mobiles, mobile)
}
// entityContactMobiles 过程库联系人的手机号集合(JSON 数组字符串):
// 优先取新的 mobiles 字段,缺省时回退旧的单个 mobile。
func entityContactMobiles(src models.TenantCrmEntityContact) string {
if s := strings.TrimSpace(src.Mobiles); s != "" {
return s
}
return buildMobiles(src.Mobile)
}
// mobilesMatched 判断两组手机号是否有交集(JSON 数组或分隔符字符串)
func mobilesMatched(a, b string) bool {
for _, x := range splitMobiles(a) {
if x == "" {
continue
}
for _, y := range splitMobiles(b) {
if x == y {
return true
}
}
}
return false
}
// mergeCompanyContact 合并过程库联系人到正式库联系人:仅补齐正式库空缺字段,不覆盖已有数据。
func mergeCompanyContact(dst *models.ErpCompanyContact, src models.TenantCrmEntityContact) {
changed := false
@@ -180,9 +204,11 @@ func mergeCompanyContact(dst *models.ErpCompanyContact, src models.TenantCrmEnti
dst.Position = strings.TrimSpace(src.Position)
changed = true
}
if strings.TrimSpace(dst.Mobiles) == "" && strings.TrimSpace(src.Mobile) != "" {
dst.Mobiles = buildMobiles(src.Mobile)
changed = true
if strings.TrimSpace(dst.Mobiles) == "" {
if m := entityContactMobiles(src); m != "" {
dst.Mobiles = m
changed = true
}
}
if dst.Remark == "" && strings.TrimSpace(src.Remark) != "" {
dst.Remark = strings.TrimSpace(src.Remark)
@@ -213,7 +239,8 @@ func syncContactsToCompany(tenantID string, customerID uint64, contacts []models
if name == "" {
continue
}
mobile := strings.TrimSpace(src.Mobile)
mobiles := entityContactMobiles(src)
mobile := firstMobile(mobiles)
// 本次待同步集合内去重(同名同手机只处理一次)
key := name + "|" + mobile
if seen[key] {
@@ -232,8 +259,8 @@ func syncContactsToCompany(tenantID string, customerID uint64, contacts []models
matched := false
for i := range existingList {
// 有手机号时必须手机号匹配;无手机号时按同名视为同一人
if mobile == "" || mobileInMobiles(mobile, existingList[i].Mobiles) {
// 有手机号时手机号需有交集才视为同一人;无手机号时按同名视为同一人
if mobile == "" || mobilesMatched(mobiles, existingList[i].Mobiles) {
mergeCompanyContact(&existingList[i], src)
merged++
matched = true
@@ -250,7 +277,7 @@ func syncContactsToCompany(tenantID string, customerID uint64, contacts []models
CompanyID: customerID,
Name: name,
Phone: "",
Mobiles: buildMobiles(mobile),
Mobiles: mobiles,
Wechat: strings.TrimSpace(src.Wechat),
QQ: strings.TrimSpace(src.QQ),
Email: strings.TrimSpace(src.Email),
+26 -4
View File
@@ -30,11 +30,20 @@ type crmProductPayload struct {
TaxRate float64 `json:"tax_rate"`
LineType string `json:"line_type"`
DevUnitPrice float64 `json:"dev_unit_price"`
DevMode string `json:"dev_mode"`
DevTree json.RawMessage `json:"dev_tree"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
// normalizeDevMode 规范软件开发「研发方式」:self=自行研发(默认)/ outsource=外协研发(别人帮我们研发)。
func normalizeDevMode(v string) string {
if strings.TrimSpace(v) == "outsource" {
return "outsource"
}
return "self"
}
// crmProductItem 项目产品清单中的单行(也用于项目生成时写入产品管理)。
type crmProductItem struct {
ProductNo string `json:"product_no"`
@@ -148,9 +157,11 @@ func (c *BackendCrmProductController) Create() {
}
// 非开发行不保留开发字段,避免残留(如 dev_tree="[]")导致清单调用时被误判为软件开发行
devUnitPrice := p.DevUnitPrice
devMode := normalizeDevMode(p.DevMode)
devTree := string(p.DevTree)
if lineType != "dev" {
devUnitPrice = 0
devMode = ""
devTree = ""
}
now := time.Now()
@@ -166,6 +177,7 @@ func (c *BackendCrmProductController) Create() {
TaxRate: p.TaxRate,
LineType: lineType,
DevUnitPrice: devUnitPrice,
DevMode: devMode,
DevTree: devTree,
Status: status,
Remark: p.Remark,
@@ -221,9 +233,11 @@ func (c *BackendCrmProductController) Update() {
}
// 非开发行清空开发字段,避免残留导致清单调用时被误判为软件开发行
devUnitPrice := p.DevUnitPrice
devMode := normalizeDevMode(p.DevMode)
devTree := string(p.DevTree)
if lineType != "dev" {
devUnitPrice = 0
devMode = ""
devTree = ""
}
now := time.Now()
@@ -240,6 +254,7 @@ func (c *BackendCrmProductController) Update() {
"tax_rate": p.TaxRate,
"line_type": lineType,
"dev_unit_price": devUnitPrice,
"dev_mode": devMode,
"dev_tree": devTree,
"status": status,
"remark": p.Remark,
@@ -454,9 +469,10 @@ func SyncContractProducts(tenantID, uid string, projectID uint64, projectName st
CreateTime: now,
UpdateTime: now,
}
// 软件开发行:写入统一人天单价与模块树,作为可复用的「开发包」
// 软件开发行:写入统一人天单价、研发方式与模块树,作为可复用的「开发包」
if lineType == "dev" {
row.DevUnitPrice = toFloat64(it["dev_unit_price"])
row.DevMode = normalizeDevMode(productFieldStr(it["dev_mode"]))
if tree := it["dev_tree"]; tree != nil {
if b, me := json.Marshal(tree); me == nil {
row.DevTree = string(b)
@@ -504,7 +520,7 @@ func productFieldStr(v interface{}) string {
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
// syncDevTreeToProduct 软件开发行引用到产品档案时,回写最新模块树与人天单价
// syncDevTreeToProduct 软件开发行引用到产品档案时,回写最新模块树、人天单价与研发方式
// (仅在有变化时更新;销售/成本单价不回写,仍由产品管理主导),
// 保证后续合同再选该产品能拿到最新的开发包。
func syncDevTreeToProduct(p *models.TenantCrmProduct, it map[string]interface{}) {
@@ -521,12 +537,18 @@ func syncDevTreeToProduct(p *models.TenantCrmProduct, it map[string]interface{})
}
newTree := string(b)
newUnitPrice := toFloat64(it["dev_unit_price"])
if newTree == p.DevTree && newUnitPrice == p.DevUnitPrice {
// 研发方式:清单行传了值就跟随回写(自行研发 / 外协研发)
newMode := p.DevMode
if m := productFieldStr(it["dev_mode"]); m != "" {
newMode = normalizeDevMode(m)
}
if newTree == p.DevTree && newUnitPrice == p.DevUnitPrice && newMode == p.DevMode {
return
}
p.DevTree = newTree
p.DevUnitPrice = newUnitPrice
_, _ = models.Orm.Update(p, "DevTree", "DevUnitPrice")
p.DevMode = newMode
_, _ = models.Orm.Update(p, "DevTree", "DevUnitPrice", "DevMode")
}
// genProductNo 生成产品编号 P-yyyymmdd-4位序号,并保证本租户内不重复。
@@ -26,6 +26,85 @@ type crmProductCategoryPayload struct {
Remark string `json:"remark"`
}
// crmDefaultCategoryName 系统默认产品分类名称:「未分类」。
// 租户级默认分类,由系统确保存在,不可编辑、不可删除;其余分类均为租户自定义(可增删改)。
const crmDefaultCategoryName = "未分类"
// crmSystemCategoryItem 系统默认产品分类定义:排序取负数,保证始终排在租户自定义分类(sort>=0)之前。
type crmSystemCategoryItem struct {
Name string
Sort int
Remark string
}
// crmSystemDefaultCategories 系统默认产品分类清单:
// - 未分类:新建/同步缺省归类,置顶;
// - 软件开发(含自行研发与外协研发)、软件产品、硬件产品:业务三大类,开箱即用。
//
// 名称含「软件」的分类计入合同软件部分、含「硬件」的计入硬件部分(与金额归属口径一致)。
var crmSystemDefaultCategories = []crmSystemCategoryItem{
{crmDefaultCategoryName, -100, "系统默认分类(不可编辑、不可删除)"},
{"软件开发", -30, "系统默认分类(不可编辑、不可删除);含自行研发与外协研发"},
{"软件产品", -20, "系统默认分类(不可编辑、不可删除)"},
{"硬件产品", -10, "系统默认分类(不可编辑、不可删除)"},
}
// crmIsSystemCategory 判断是否为系统级分类(不允许编辑、不允许删除)。
// 双保险:is_default 标记命中,或名称命中系统内置清单
// (防止历史数据标记被意外清零后,系统分类被删除破坏语义)。
func crmIsSystemCategory(row *models.TenantCrmProductCategory) bool {
if row == nil {
return false
}
if row.IsDefault == 1 {
return true
}
for _, item := range crmSystemDefaultCategories {
if row.Name == item.Name {
return true
}
}
return false
}
// crmEnsureDefaultProductCategories 确保租户存在系统默认分类(未分类 / 软件开发 / 软件产品 / 硬件产品):
// - 不存在(含曾被软删除)时创建 / 恢复,保证其始终可用(复用软删记录,避免唯一键冲突);
// - 已存在同名分类(租户此前自建)时升级为系统默认,并修复默认标记、状态与内置排序(历史数据升级自修复)。
func crmEnsureDefaultProductCategories(tenantID string) {
if models.Orm == nil || strings.TrimSpace(tenantID) == "" {
return
}
now := time.Now()
table := new(models.TenantCrmProductCategory).TableName()
for _, item := range crmSystemDefaultCategories {
var row models.TenantCrmProductCategory
err := models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("tenant_id", tenantID).
Filter("name", item.Name).
One(&row)
if err == nil && row.ID > 0 {
// 已存在(含软删除):恢复并修复默认标记、启用状态与内置排序
if row.DeleteTime != nil || row.IsDefault != 1 || row.Sort != item.Sort || row.Status != 1 {
_, _ = models.Orm.Raw("UPDATE "+table+
" SET delete_time = NULL, is_default = 1, sort = ?, status = 1, update_time = ? WHERE id = ?",
item.Sort, now, row.ID).Exec()
}
continue
}
newRow := models.TenantCrmProductCategory{
TenantID: tenantID,
Name: item.Name,
Sort: item.Sort,
Status: 1,
IsDefault: 1,
Remark: item.Remark,
CreateTime: now,
UpdateTime: now,
}
_, _ = models.Orm.Insert(&newRow)
}
}
// List GET /backend/crm/product/category/list
func (c *BackendCrmProductCategoryController) List() {
claims, err := pipelineClaims(&c.Controller)
@@ -45,6 +124,8 @@ func (c *BackendCrmProductCategoryController) List() {
status := strings.TrimSpace(c.GetString("status"))
tenantID := pipelineTenantID(claims)
// 确保系统默认分类「未分类」存在(首次访问自动创建 / 恢复)
crmEnsureDefaultProductCategories(tenantID)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if keyword != "" {
kw := orm.NewCondition().
@@ -165,6 +246,11 @@ func (c *BackendCrmProductCategoryController) Update() {
pipelineErr(&c.Controller, 404, 404, "产品分类未找到")
return
}
// 系统默认分类只读:不可编辑(防止改名破坏系统分类语义)
if crmIsSystemCategory(&row) {
pipelineErr(&c.Controller, 400, 400, "系统默认分类不可编辑")
return
}
status := p.Status
if status != 0 && status != 1 {
status = row.Status
@@ -211,6 +297,11 @@ func (c *BackendCrmProductCategoryController) Delete() {
pipelineErr(&c.Controller, 404, 404, "产品分类未找到")
return
}
// 系统默认分类只读:不可删除(系统级分类始终保留,保证产品归类语义稳定)
if crmIsSystemCategory(&row) {
pipelineErr(&c.Controller, 400, 400, "系统默认分类不可删除")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", tenantID).
+268 -25
View File
@@ -21,15 +21,37 @@ type BackendCrmProjectController struct {
beego.Controller
}
// crmProjectStatusDone 项目状态「已完成」(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常):
// 列表排列时「已完成」沉底展示,与前端筛选传参一致使用字符串值。
const crmProjectStatusDone = "3"
// projectStatusName 项目状态文案(用于操作日志)。
func projectStatusName(s int8) string {
switch s {
case 1:
return "未开始"
case 2:
return "进行中"
case 3:
return "已完成"
case 4:
return "已暂停"
case 5:
return "异常"
}
return "未知"
}
type projectPayload struct {
ProjectName string `json:"project_name"`
ProjectNo string `json:"project_no"`
// 项目类型:1=单一项目(仅上游客户) 2=上下游项目(上游客户 + 下游供应商)
// 3=上中游项目(上游 A 公司 + 中游 B 公司,下游固定为本公司)
ProjectType int8 `json:"project_type"`
CustomerID uint64 `json:"customer_id"`
CustomerName string `json:"customer_name"`
SupplierID uint64 `json:"supplier_id"`
SupplierRefType int8 `json:"supplier_ref_type"` // 下游主体来源:1客户 2供应商
SupplierRefType int8 `json:"supplier_ref_type"` // 对方主体来源:1客户 2供应商(下游 / 中游)
SupplierName string `json:"supplier_name"`
SupplierIndustry string `json:"supplier_industry"`
OwnerUserID string `json:"owner_user_id"`
@@ -57,12 +79,13 @@ type projectPayload struct {
}
// normalizeProjectFields 归一项目类型、上下游金额与上下游对接人:
// - 项目类型仅识别 2=上下游,其余一律按 1=单一项目处理;
// - 项目类型仅识别 2=上下游、3=上中游,其余一律按 1=单一项目处理;
// - 上游金额 / 上游对接人缺省时回退旧的 amount / contact_* 字段(兼容未升级的调用方);
// - 单一项目不存在下游,下游金额与下游对接人一并清空。
// - 单一项目不存在上下游,下游金额与下游对接人一并清空;
// - 3=上中游项目(上游 A 公司 + 中游 B 公司,下游固定为本公司)的中游主体复用下游字段存储。
func normalizeProjectFields(p *projectPayload) (pt int8, upstream, downstream float64, upContact, upPhone, downContact, downPhone string) {
pt = p.ProjectType
if pt != 2 {
if pt != 2 && pt != 3 {
pt = 1
}
upstream = p.UpstreamAmount
@@ -71,7 +94,8 @@ func normalizeProjectFields(p *projectPayload) (pt int8, upstream, downstream fl
}
upContact = strings.TrimSpace(firstNonEmpty(p.UpstreamContact, p.ContactPerson))
upPhone = strings.TrimSpace(firstNonEmpty(p.UpstreamPhone, p.ContactPhone))
if pt == 2 {
// 上下游项目:对方为下游(供应商/客户);上中游项目:对方为中游(中间方)——两者均落在 downstream_* 字段
if pt == 2 || pt == 3 {
downstream = p.DownstreamAmount
downContact = strings.TrimSpace(p.DownstreamContact)
downPhone = strings.TrimSpace(p.DownstreamPhone)
@@ -179,29 +203,78 @@ func (c *BackendCrmProjectController) List() {
ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
kw := orm.NewCondition().
Or("project_name__contains", keyword).
Or("project_no__contains", keyword).
Or("customer_name__contains", keyword).
Or("supplier_name__contains", keyword)
cond = cond.AndCond(kw)
}
if status != "" {
cond = cond.And("status", status)
}
if ownerID != "" {
cond = cond.And("owner_user_id", ownerID)
}
qs := models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond)
total, _ := qs.Count()
// buildCond 构造本次列表的筛选条件。
// 「已完成」沉底排列需要按「未完成 / 已完成」两组分别查询,因此这里做成可重复调用的构造方法。
buildCond := func() *orm.Condition {
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
kw := orm.NewCondition().
Or("project_name__contains", keyword).
Or("project_no__contains", keyword).
Or("customer_name__contains", keyword).
Or("supplier_name__contains", keyword)
cond = cond.AndCond(kw)
}
if status != "" {
cond = cond.And("status", status)
}
if ownerID != "" {
cond = cond.And("owner_user_id", ownerID)
}
return cond
}
projectQuery := func(cond *orm.Condition) orm.QuerySeter {
return models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond)
}
// 未完成组:除「已完成」外的全部状态(含历史数据中的空状态),沉底排列时取列表前段
unfinishedCond := func() *orm.Condition {
return buildCond().AndCond(orm.NewCondition().
Or("status__ne", crmProjectStatusDone).
Or("status__isnull", true))
}
total, _ := projectQuery(buildCond()).Count()
var list []models.TenantCrmProject
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
offset := (page - 1) * pageSize
if status != "" {
// 已按状态筛选:结果只含单一状态,直接按新→旧分页
if _, err := projectQuery(buildCond()).OrderBy("-id").Offset(offset).Limit(pageSize).All(&list); err != nil {
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
return
}
} else {
// 列表排列:未完成项目在前、「已完成」项目沉底,两组内部仍按新→旧。
// 先按两组数量把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
unfinished, _ := projectQuery(unfinishedCond()).Count()
unfinishedTotal := int(unfinished)
finishedTotal := int(total) - unfinishedTotal
end := offset + pageSize
if end > int(total) {
end = int(total)
}
aFrom, aTo := min(offset, unfinishedTotal), min(end, unfinishedTotal)
bFrom := min(max(offset-unfinishedTotal, 0), finishedTotal)
bTo := min(max(end-unfinishedTotal, 0), finishedTotal)
if aTo > aFrom {
if _, err := projectQuery(unfinishedCond()).OrderBy("-id").Offset(aFrom).Limit(aTo - aFrom).All(&list); err != nil {
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
return
}
}
if bTo > bFrom {
var finished []models.TenantCrmProject
if _, err := projectQuery(buildCond().And("status", crmProjectStatusDone)).
OrderBy("-id").Offset(bFrom).Limit(bTo - bFrom).All(&finished); err != nil {
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
return
}
list = append(list, finished...)
}
}
}
attachBusinessName(tenantID, list)
// 项目起止时间由关联合同汇总(不再依赖手工维护的开始 / 结束日期)
@@ -495,6 +568,176 @@ func (c *BackendCrmProjectController) Delete() {
pipelineOk(&c.Controller, nil)
}
// ========================== 项目联系人(从对应企业通讯录挑选) ==========================
// contactIDsJSON 把联系人ID数组序列化为 JSON 字符串;空集合返回空串。
func contactIDsJSON(ids []uint64) string {
if len(ids) == 0 {
return ""
}
b, err := json.Marshal(ids)
if err != nil {
return ""
}
return string(b)
}
// filterCompanyContactIDs 过滤出确实属于指定企业通讯录的联系人ID(防止跨企业/脏ID混入)。
// companyID 为空或 ids 为空时直接返回空集合。
func filterCompanyContactIDs(tenantID string, companyType string, companyID *uint64, ids []uint64) []uint64 {
if companyID == nil || *companyID == 0 || len(ids) == 0 {
return nil
}
var rows []models.ErpCompanyContact
_, _ = models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("tenant_id", tenantID).
Filter("company_type", companyType).
Filter("company_id", *companyID).
Filter("delete_time__isnull", true).
All(&rows)
allowed := make(map[uint64]bool, len(rows))
for _, r := range rows {
allowed[r.ID] = true
}
out := make([]uint64, 0, len(ids))
seen := make(map[uint64]bool, len(ids))
for _, id := range ids {
if id > 0 && allowed[id] && !seen[id] {
out = append(out, id)
seen[id] = true
}
}
return out
}
// SaveContacts POST /backend/crm/project/:id/contacts
// 保存项目联系人:从对应企业通讯录中挑选的联系人ID集合(支持多选)。
// - 上游=客户方(customer_id 对应通讯录);
// - 下游/中游=对方主体方(supplier_ref_type:1=客户库 2=供应商库,supplier_id 对应通讯录);
// - 单一项目(project_type=1)只保留上游集合,下游集合自动清空;
// - 传入不属于对应企业的联系人ID会被过滤掉。
func (c *BackendCrmProjectController) SaveContacts() {
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 {
UpstreamContactIDs []uint64 `json:"upstream_contact_ids"`
DownstreamContactIDs []uint64 `json:"downstream_contact_ids"`
}
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var proj models.TenantCrmProject
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&proj); err != nil {
pipelineErr(&c.Controller, 404, 404, "项目未找到")
return
}
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据")
return
}
pt := proj.ProjectType
if pt != 2 && pt != 3 {
pt = 1
}
upIDs := filterCompanyContactIDs(tenantID, "customer", proj.CustomerID, p.UpstreamContactIDs)
var downIDs []uint64
// 上下游 / 上中游项目:对方主体(下游 / 中游)联系人按 supplier_ref_type 选择对应库
if pt != 1 && proj.SupplierID != nil && *proj.SupplierID > 0 {
companyType := "supplier"
if proj.SupplierRefType == 1 {
companyType = "customer"
}
downIDs = filterCompanyContactIDs(tenantID, companyType, proj.SupplierID, p.DownstreamContactIDs)
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", proj.ID).Filter("tenant_id", tenantID).
Update(map[string]interface{}{
"upstream_contact_ids": contactIDsJSON(upIDs),
"downstream_contact_ids": contactIDsJSON(downIDs),
"update_time": now,
})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "保存项目联系人失败: "+err.Error())
return
}
crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目联系人:"+proj.ProjectName, claims)
pipelineOk(&c.Controller, map[string]interface{}{
"upstream_contact_ids": upIDs,
"downstream_contact_ids": downIDs,
})
}
// ChangeStatus POST /backend/crm/project/:id/status
// 项目状态流转:1=未开始 2=进行中 3=已完成 4=已暂停 5=异常。
// 各状态间可互切;仅更新状态字段,不触碰其他业务字段。
func (c *BackendCrmProjectController) 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
}
if p.Status < 1 || p.Status > 5 {
pipelineErr(&c.Controller, 400, 400, "无效的状态值")
return
}
tenantID := pipelineTenantID(claims)
var proj models.TenantCrmProject
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&proj); err != nil {
pipelineErr(&c.Controller, 404, 404, "项目未找到")
return
}
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据")
return
}
if proj.Status == p.Status {
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID, "status": proj.Status})
return
}
if _, err := models.Orm.QueryTable(new(models.TenantCrmProject)).
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, proj.ID, "status",
"项目状态流转:"+projectStatusName(proj.Status)+" → "+projectStatusName(p.Status), claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID, "status": p.Status})
}
// ========================== 项目文档(OA 文档库绑定) ==========================
// projectDocItem 项目文档条目:文档库文档 + 所在文件夹名称。
+9 -3
View File
@@ -490,7 +490,9 @@ type entityContactPayload struct {
ContactName string `json:"contact_name"`
Position string `json:"position"`
Mobile string `json:"mobile"`
Wechat string `json:"wechat"`
// 手机号可登记多个(有的人有多个号码):优先使用,缺省时回退单个 mobile
Mobiles []string `json:"mobiles"`
Wechat string `json:"wechat"`
QQ string `json:"qq"`
Email string `json:"email"`
IsPrimary int8 `json:"is_primary"`
@@ -515,13 +517,16 @@ func (c *BackendCrmEntityContactController) Add() {
return
}
tenantID := pipelineTenantID(claims)
// 手机号支持多个:优先取 mobiles 数组,缺省回退单个 mobile;mobile 保留为第一个号码
mobilesJSON := normalizeMobiles(p.Mobiles, p.Mobile)
row := models.TenantCrmEntityContact{
TenantID: tenantID,
RelatedType: p.RelatedType,
RelatedID: p.RelatedID,
ContactName: strings.TrimSpace(p.ContactName),
Position: strings.TrimSpace(p.Position),
Mobile: strings.TrimSpace(p.Mobile),
Mobile: firstMobile(mobilesJSON),
Mobiles: mobilesJSON,
Wechat: strings.TrimSpace(p.Wechat),
QQ: strings.TrimSpace(p.QQ),
Email: strings.TrimSpace(p.Email),
@@ -568,7 +573,8 @@ func (c *BackendCrmEntityContactController) Edit() {
}
row.ContactName = strings.TrimSpace(p.ContactName)
row.Position = strings.TrimSpace(p.Position)
row.Mobile = strings.TrimSpace(p.Mobile)
row.Mobiles = normalizeMobiles(p.Mobiles, p.Mobile)
row.Mobile = firstMobile(row.Mobiles)
row.Wechat = strings.TrimSpace(p.Wechat)
row.QQ = strings.TrimSpace(p.QQ)
row.Email = strings.TrimSpace(p.Email)
+36 -3
View File
@@ -63,16 +63,37 @@ var crmDefaultUnits = []struct {
// crmEnsureDefaultUnits 租户首次使用(从未有过任何单位记录,含已软删除的)时写入默认单位。
// 用户手动清空后不再自动补,避免越删越有;需要恢复时用「恢复默认单位」接口。
// 已有记录时顺带修复存量默认单位的 is_default 标记(历史数据升级)。
func crmEnsureDefaultUnits(tenantID string) {
if models.Orm == nil || strings.TrimSpace(tenantID) == "" {
return
}
cnt, err := models.Orm.QueryTable(new(models.TenantCrmUnit)).
Filter("tenant_id", tenantID).Count()
if err != nil || cnt > 0 {
if err != nil {
return
}
crmInsertDefaultUnits(tenantID, nil)
if cnt == 0 {
crmInsertDefaultUnits(tenantID, nil)
return
}
crmMarkDefaultUnits(tenantID)
}
// crmMarkDefaultUnits 将默认清单中已存在的存量单位标记为 is_default=1(历史数据升级自修复,
// 避免早期写入的默认单位因未打标而被误编辑/删除)。
func crmMarkDefaultUnits(tenantID string) {
names := make([]string, 0, len(crmDefaultUnits))
for _, u := range crmDefaultUnits {
names = append(names, u.Name)
}
if len(names) == 0 {
return
}
_, _ = models.Orm.QueryTable(new(models.TenantCrmUnit)).
Filter("tenant_id", tenantID).
Filter("name__in", names).
Update(orm.Params{"is_default": 1})
}
// crmInsertDefaultUnits 写入默认单位,跳过已存在的名称(existing 为已存在单位名集合)。
@@ -90,6 +111,7 @@ func crmInsertDefaultUnits(tenantID string, existing map[string]bool) int {
Code: u.Code,
Sort: i + 1,
Status: 1,
IsDefault: 1, // 系统默认单位:不可编辑、不可删除
CreateTime: now,
UpdateTime: now,
}
@@ -129,7 +151,7 @@ func crmInitDefaultUnits(tenantID string) int {
}
if id, ok := deleted[u.Name]; ok {
if _, err := models.Orm.Raw("UPDATE "+table+
" SET delete_time = NULL, status = 1, sort = ?, update_time = ? WHERE id = ?",
" SET delete_time = NULL, status = 1, is_default = 1, sort = ?, update_time = ? WHERE id = ?",
i+1, now, id).Exec(); err == nil {
existing[u.Name] = true
added++
@@ -142,6 +164,7 @@ func crmInitDefaultUnits(tenantID string) int {
Code: u.Code,
Sort: i + 1,
Status: 1,
IsDefault: 1, // 系统默认单位:不可编辑、不可删除
CreateTime: now,
UpdateTime: now,
}
@@ -359,6 +382,11 @@ func (c *BackendCrmUnitController) Update() {
pipelineErr(&c.Controller, 404, 404, "单位未找到")
return
}
// 系统默认单位只读:不可编辑
if row.IsDefault == 1 {
pipelineErr(&c.Controller, 400, 400, "系统默认单位不可编辑")
return
}
status := p.Status
if status != 0 && status != 1 {
status = row.Status
@@ -416,6 +444,11 @@ func (c *BackendCrmUnitController) Delete() {
pipelineErr(&c.Controller, 404, 404, "单位未找到")
return
}
// 系统默认单位只读:不可删除
if row.IsDefault == 1 {
pipelineErr(&c.Controller, 400, 400, "系统默认单位不可删除")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmUnit)).
Filter("id", id).Filter("tenant_id", tenantID).
+53 -1
View File
@@ -136,8 +136,18 @@ func (c *BackendErpCustomerController) List() {
return
}
// 标记「同时也是供应商」:同一家单位可能既是客户又是供应商,列表上直接可见,避免重复转化
alsoSupplier := partyNameExists(fmt.Sprintf("%d", claims.TenantId), "supplier", collectCustomerNames(list))
rows := make([]customerListRow, 0, len(list))
for _, r := range list {
rows = append(rows, customerListRow{
TenantCrmCustomer: r,
AlsoSupplier: alsoSupplier[strings.TrimSpace(r.CustomerName)],
})
}
c.customerOk(map[string]interface{}{
"list": list,
"list": rows,
"total": total,
"page": page,
"pageSize": pageSize,
@@ -172,6 +182,48 @@ func (c *BackendErpCustomerController) Detail() {
c.customerOk(customer)
}
// ConvertToSupplier POST /backend/erp/customer/:id/convert-supplier
// 客户转供应商:把客户档案(含联系人)复制一套到供应商库。
// 同一家单位可能既是客户又是供应商,因此原客户记录保留、不删除;
// 供应商库已存在同名企业时不重复建档,只把联系人补同步过去。
func (c *BackendErpCustomerController) ConvertToSupplier() {
claims, err := c.customerClaims()
if err != nil {
c.customerJsonErr(401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.customerJsonErr(400, 400, "无效的ID")
return
}
tenantID := fmt.Sprintf("%d", claims.TenantId)
var customer models.TenantCrmCustomer
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
Filter("id", id).
Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).
One(&customer); err != nil {
c.customerJsonErr(404, 404, "客户未找到")
return
}
existed, supplierID, inserted, merged, cerr := convertCustomerToSupplier(tenantID, &customer, claims)
if cerr != nil {
c.customerJsonErr(500, 500, cerr.Error())
return
}
c.customerOk(map[string]interface{}{
"id": supplierID,
"existed": existed,
"contacts": map[string]interface{}{
"inserted": inserted,
"merged": merged,
},
})
}
// Create POST /backend/erp/customer
func (c *BackendErpCustomerController) Create() {
claims, err := c.customerClaims()
+53 -1
View File
@@ -134,8 +134,18 @@ func (c *BackendErpSupplierController) List() {
return
}
// 标记「同时也是客户」:同一家单位可能既是供应商又是客户,列表上直接可见,避免重复转化
alsoCustomer := partyNameExists(fmt.Sprintf("%d", claims.TenantId), "customer", collectSupplierNames(list))
rows := make([]supplierListRow, 0, len(list))
for _, r := range list {
rows = append(rows, supplierListRow{
TenantCrmSupplier: r,
AlsoCustomer: alsoCustomer[strings.TrimSpace(r.SupplierName)],
})
}
c.supplierOk(map[string]interface{}{
"list": list,
"list": rows,
"total": total,
"page": page,
"pageSize": pageSize,
@@ -170,6 +180,48 @@ func (c *BackendErpSupplierController) Detail() {
c.supplierOk(supplier)
}
// ConvertToCustomer POST /backend/erp/supplier/:id/convert-customer
// 供应商转客户:把供应商档案(含联系人)复制一套到客户库。
// 同一家单位可能既是客户又是供应商,因此原供应商记录保留、不删除;
// 客户库已存在同名企业时不重复建档,只把联系人补同步过去。
func (c *BackendErpSupplierController) ConvertToCustomer() {
claims, err := c.supplierClaims()
if err != nil {
c.supplierJsonErr(401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.supplierJsonErr(400, 400, "无效的ID")
return
}
tenantID := fmt.Sprintf("%d", claims.TenantId)
var supplier models.TenantCrmSupplier
if err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
Filter("id", id).
Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).
One(&supplier); err != nil {
c.supplierJsonErr(404, 404, "供应商未找到")
return
}
existed, customerID, inserted, merged, cerr := convertSupplierToCustomer(tenantID, &supplier, claims)
if cerr != nil {
c.supplierJsonErr(500, 500, cerr.Error())
return
}
c.supplierOk(map[string]interface{}{
"id": customerID,
"existed": existed,
"contacts": map[string]interface{}{
"inserted": inserted,
"merged": merged,
},
})
}
// Create POST /backend/erp/supplier
func (c *BackendErpSupplierController) Create() {
claims, err := c.supplierClaims()