批量更新功能
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 项目文档条目:文档库文档 + 所在文件夹名称。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- =============================================================
|
||||
-- Product dev_mode: distinguish self-developed vs outsourced
|
||||
-- (software development has two kinds: developed by ourselves,
|
||||
-- or developed by others for us)
|
||||
-- Table: yz_backend_crm_product
|
||||
-- dev_mode varchar(20) NOT NULL DEFAULT 'self'
|
||||
-- 'self' -> developed in-house (default)
|
||||
-- 'outsource' -> outsourced / developed by others for us
|
||||
-- Note: the server also auto-adds this column on startup
|
||||
-- (models.EnsureCrmProductDevColumns); this file is only
|
||||
-- for manual repair. Re-running reports
|
||||
-- "Duplicate column name", which is safe.
|
||||
-- Run: mysql -h<host> -P<port> -u<user> -p <db> < alter_backend_crm_product_dev_mode.sql
|
||||
-- NOTE: keep this file ASCII-only; piping through some shells mangles non-ASCII.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE `yz_backend_crm_product`
|
||||
ADD COLUMN `dev_mode` VARCHAR(20) NOT NULL DEFAULT 'self'
|
||||
COMMENT 'software dev mode: self in-house / outsource developed by others'
|
||||
AFTER `dev_unit_price`;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- =============================================================
|
||||
-- Contacts: support multiple mobile numbers (a person may have several)
|
||||
-- Tables:
|
||||
-- 1) yz_backend_crm_entity_contact : contacts of clue / business / project
|
||||
-- 2) yz_backend_erp_contact : global address book contacts
|
||||
-- 3) yz_backend_contact_company : company contacts (customers/suppliers),
|
||||
-- shared by project contact picker / address book / customer-supplier
|
||||
-- conversion. This one was missed before: without the column any SELECT
|
||||
-- fails and contact lists show as empty (fixed by the server-side
|
||||
-- migration EnsureErpCompanyContactMobilesColumn on startup).
|
||||
-- All tables get a new column `mobiles` (TEXT, JSON array, max 5).
|
||||
-- The old `mobile` / `phone` column keeps the FIRST number for backward
|
||||
-- compatibility (list display, phone-based dedupe on sync), and existing
|
||||
-- single numbers are backfilled into `mobiles`.
|
||||
-- Run: mysql -h<host> -P<port> -u<user> -p <db> < alter_contact_mobiles.sql
|
||||
-- (re-running only reports "Duplicate column name", which is safe)
|
||||
-- NOTE: keep this file ASCII-only; piping through some shells mangles non-ASCII.
|
||||
-- =============================================================
|
||||
|
||||
-- 1) clue / business / project contacts
|
||||
ALTER TABLE `yz_backend_crm_entity_contact`
|
||||
ADD COLUMN `mobiles` TEXT NULL COMMENT 'phones in JSON array, max 5' AFTER `mobile`;
|
||||
|
||||
-- backfill existing single number as a one-element JSON array
|
||||
UPDATE `yz_backend_crm_entity_contact`
|
||||
SET `mobiles` = JSON_ARRAY(`mobile`)
|
||||
WHERE (`mobiles` IS NULL OR `mobiles` = '')
|
||||
AND `mobile` IS NOT NULL AND `mobile` <> '';
|
||||
|
||||
-- 2) global address book contacts
|
||||
ALTER TABLE `yz_backend_erp_contact`
|
||||
ADD COLUMN `mobiles` TEXT NULL COMMENT 'phones in JSON array, max 5' AFTER `phone`;
|
||||
|
||||
UPDATE `yz_backend_erp_contact`
|
||||
SET `mobiles` = JSON_ARRAY(`phone`)
|
||||
WHERE (`mobiles` IS NULL OR `mobiles` = '')
|
||||
AND `phone` IS NOT NULL AND `phone` <> '';
|
||||
|
||||
-- 3) company contacts (customers / suppliers) - shared by project contact
|
||||
-- picker / address book / customer-supplier conversion.
|
||||
-- NOTE: also auto-added on server startup (EnsureErpCompanyContactMobilesColumn).
|
||||
ALTER TABLE `yz_backend_contact_company`
|
||||
ADD COLUMN `mobiles` TEXT NULL COMMENT 'phones in JSON array, max 5' AFTER `phone`;
|
||||
|
||||
UPDATE `yz_backend_contact_company`
|
||||
SET `mobiles` = JSON_ARRAY(`phone`)
|
||||
WHERE (`mobiles` IS NULL OR `mobiles` = '')
|
||||
AND `phone` IS NOT NULL AND `phone` <> '';
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =============================================================
|
||||
-- CRM product categories: system default category "未分类"
|
||||
-- Table: yz_backend_crm_product_category
|
||||
-- is_default = 1 -> system default category ("未分类"): kept by
|
||||
-- the system, cannot be edited or deleted,
|
||||
-- sort pinned to the top (-1)
|
||||
-- is_default = 0 -> tenant custom category (full CRUD)
|
||||
-- Existing "未分类" rows (if any) are marked as default below;
|
||||
-- the backend also self-heals the flag and creates the record
|
||||
-- on first use after upgrade.
|
||||
-- Run: mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < alter_crm_product_category_default.sql
|
||||
-- (re-running only reports "Duplicate column name", which is safe)
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE `yz_backend_crm_product_category`
|
||||
ADD COLUMN `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '1=system default category (read-only), 0=tenant custom' AFTER `status`;
|
||||
|
||||
-- Mark existing default category (same name as backend default) and pin it to the top
|
||||
UPDATE `yz_backend_crm_product_category`
|
||||
SET `is_default` = 1, `sort` = -1
|
||||
WHERE `name` = '未分类';
|
||||
@@ -0,0 +1,19 @@
|
||||
-- =============================================================
|
||||
-- CRM units: mark system default units as not editable/deletable
|
||||
-- Table: yz_backend_crm_unit
|
||||
-- is_default = 1 -> system default unit (seeded by the system,
|
||||
-- cannot be edited or deleted by tenants)
|
||||
-- is_default = 0 -> tenant custom unit (full CRUD)
|
||||
-- Existing seeded default units (matched by name) are marked below;
|
||||
-- the backend also self-heals the flag on first use after upgrade.
|
||||
-- Run: mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < alter_crm_unit_default.sql
|
||||
-- (re-running only reports "Duplicate column name", which is safe)
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE `yz_backend_crm_unit`
|
||||
ADD COLUMN `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '1=system default unit (read-only), 0=tenant custom' AFTER `status`;
|
||||
|
||||
-- Mark seeded default units (same list as backend crmDefaultUnits)
|
||||
UPDATE `yz_backend_crm_unit`
|
||||
SET `is_default` = 1
|
||||
WHERE `name` IN ('套', '台', '个', '件', '批', '次', '项', '人月', '人天', '年', '月', '米', '平方米', '吨', '千克', '箱');
|
||||
@@ -0,0 +1,16 @@
|
||||
-- =============================================================
|
||||
-- Payback records: mark which plan item (progress node / period)
|
||||
-- a received payment belongs to, so payments can be registered
|
||||
-- per progress node and shown as tree children under the plan.
|
||||
-- Table: yz_backend_crm_payback_record
|
||||
-- item_seq : seq of the plan items entry (1-based), 0 = not specified
|
||||
-- item_name : node name snapshot (e.g. "预付款")
|
||||
-- Run: mysql -h<host> -P<port> -u<user> -p <db> < alter_payback_record_item.sql
|
||||
-- (re-running only reports "Duplicate column name", which is safe)
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE `yz_backend_crm_payback_record`
|
||||
ADD COLUMN `item_seq` INT NOT NULL DEFAULT 0 COMMENT 'plan item seq (1-based), 0 = not specified' AFTER `payback_id`;
|
||||
|
||||
ALTER TABLE `yz_backend_crm_payback_record`
|
||||
ADD COLUMN `item_name` VARCHAR(50) NULL COMMENT 'plan item name snapshot' AFTER `item_seq`;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =============================================================
|
||||
-- Project contacts: pick contacts from the linked companies
|
||||
-- (a project no longer shows ALL contacts of the customer)
|
||||
-- Table: yz_backend_crm_project
|
||||
-- 1) upstream_contact_ids : JSON array of contact IDs picked from
|
||||
-- the customer's address book (upstream).
|
||||
-- 2) downstream_contact_ids : JSON array of contact IDs picked from
|
||||
-- the counterpart's address book
|
||||
-- (downstream for type=2 / midstream for type=3).
|
||||
-- Contact details always come from yz_backend_erp_contact in real time;
|
||||
-- only the picked ID references are stored here. Empty array -> ''.
|
||||
-- Run: mysql -h<host> -P<port> -u<user> -p <db> < alter_project_contacts.sql
|
||||
-- (re-running only reports "Duplicate column name", which is safe)
|
||||
-- NOTE: keep this file ASCII-only; piping through some shells mangles non-ASCII.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE `yz_backend_crm_project`
|
||||
ADD COLUMN `upstream_contact_ids` TEXT NULL COMMENT 'picked contact IDs (JSON array) from customer address book' AFTER `downstream_phone`;
|
||||
|
||||
ALTER TABLE `yz_backend_crm_project`
|
||||
ADD COLUMN `downstream_contact_ids` TEXT NULL COMMENT 'picked contact IDs (JSON array) from counterpart address book (downstream/midstream)' AFTER `upstream_contact_ids`;
|
||||
@@ -30,5 +30,8 @@ func main() {
|
||||
// 启动自有域名证书续期任务(到期前 30 天自动续签 Let's Encrypt 证书)
|
||||
services.StartCertRenewScheduler(make(chan struct{}))
|
||||
|
||||
// 启动回款逾期联动任务(回款逾期 → 合同「异常」→ 关联项目「异常」)
|
||||
services.StartPaybackOverdueScheduler(make(chan struct{}))
|
||||
|
||||
beego.Run()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ type BackendErpContact struct {
|
||||
ContactType int8 `orm:"column(contact_type);default(1)" json:"contact_type"`
|
||||
ContactName string `orm:"column(contact_name);size(64)" json:"contact_name"`
|
||||
Gender int8 `orm:"column(gender);default(0)" json:"gender"`
|
||||
Phone *string `orm:"column(phone);size(20);null" json:"phone"`
|
||||
Phone *string `orm:"column(phone);size(20);null" json:"phone"` // 手机号(保留第一个,兼容旧逻辑)
|
||||
// 手机号可登记多个(有的人有多个号码):JSON 数组,最多 5 个,phone 始终与其中一个(第一个)保持一致
|
||||
Mobiles *string `orm:"column(mobiles);type(text);null" json:"mobiles"`
|
||||
WorkPhone *string `orm:"column(work_phone);size(20);null" json:"work_phone"`
|
||||
Email *string `orm:"column(email);size(128);null" json:"email"`
|
||||
Wechat *string `orm:"column(wechat);size(64);null" json:"wechat"`
|
||||
|
||||
+20
-5
@@ -156,6 +156,7 @@ func Init(_ string) {
|
||||
EnsureCrmPaybackRecordTable()
|
||||
EnsureCrmProductDevColumns()
|
||||
EnsureCrmUnitTable()
|
||||
EnsureErpCompanyContactMobilesColumn()
|
||||
}
|
||||
|
||||
// EnsureCrmPaybackTable 回款计划表建表(CREATE TABLE IF NOT EXISTS,可重复执行;
|
||||
@@ -278,7 +279,7 @@ func EnsureCrmContractTable() {
|
||||
total_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总金额',
|
||||
total_cost decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '产品总成本',
|
||||
total_profit decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总利润',
|
||||
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1草稿/2已完成/3已作废/4履约中/5执行异常',
|
||||
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1草稿/2已完成/3已作废/4履约中/5异常',
|
||||
step tinyint(4) NOT NULL DEFAULT '1' COMMENT '进度步骤:1合同信息/2产品清单',
|
||||
remark text COMMENT '备注',
|
||||
create_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
|
||||
@@ -335,13 +336,30 @@ func EnsureCrmUnitTable() {
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCrmProductDevColumns 补齐产品表的软件开发字段(line_type / dev_unit_price / dev_tree);
|
||||
// EnsureErpCompanyContactMobilesColumn 补齐公司联系人表(yz_backend_contact_company)的多手机号字段 mobiles。
|
||||
// 该表为客户/供应商正式联系人的共用库(项目联系人选择、通讯录、客户-供应商转化等均读取),
|
||||
// 早期多手机号升级遗漏了此表,缺列会导致查询报错(前端表现为「联系人不显示」)。
|
||||
// 表不存在或列已存在时忽略错误,可用 docs/sql/alter_contact_mobiles.sql 手动修复。
|
||||
func EnsureErpCompanyContactMobilesColumn() {
|
||||
table := new(ErpCompanyContact).TableName()
|
||||
sql := fmt.Sprintf("ALTER TABLE %s ADD COLUMN mobiles text NULL COMMENT '手机号 JSON 数组(最多5个),phone 保留座机语义'", table)
|
||||
if _, err := Orm.Raw(sql).Exec(); err != nil {
|
||||
// 列已存在或表不存在时忽略
|
||||
return
|
||||
}
|
||||
// 历史数据回填:原 phone 中的号码作为第一个手机号(仅在 mobiles 为空时)
|
||||
_, _ = Orm.Raw("UPDATE " + table +
|
||||
" SET mobiles = JSON_ARRAY(phone) WHERE (mobiles IS NULL OR mobiles = '') AND phone IS NOT NULL AND phone <> ''").Exec()
|
||||
}
|
||||
|
||||
// EnsureCrmProductDevColumns 补齐产品表的软件开发字段(line_type / dev_unit_price / dev_mode / dev_tree);
|
||||
// 表不存在或列已存在时忽略错误,可用 docs/sql/alter_backend_crm_product_dev_columns.sql 手动修复。
|
||||
func EnsureCrmProductDevColumns() {
|
||||
table := new(TenantCrmProduct).TableName()
|
||||
cols := []string{
|
||||
"line_type varchar(20) NOT NULL DEFAULT 'product' COMMENT '行类型:product成品/dev软件开发'",
|
||||
"dev_unit_price decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '软件开发统一人天单价'",
|
||||
"dev_mode varchar(20) NOT NULL DEFAULT 'self' COMMENT '软件开发研发方式:self自行研发/outsource外协研发'",
|
||||
"dev_tree text COMMENT '软件开发模块树JSON(叶子人天×统一人天单价)'",
|
||||
}
|
||||
for _, col := range cols {
|
||||
@@ -353,9 +371,6 @@ func EnsureCrmProductDevColumns() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// EnsureCrmCreateUserColumn 补齐客户/供应商的创建人字段(用于删除权限校验)。
|
||||
// 同样兼容历史表名差异,表不存在或列已存在时忽略错误。
|
||||
func EnsureCrmCreateUserColumn() {
|
||||
|
||||
@@ -22,7 +22,7 @@ type TenantCrmClue struct {
|
||||
ContactWechat string `orm:"column(contact_wechat);size(64)" json:"contact_wechat"` // 对接人微信
|
||||
ContactQQ string `orm:"column(contact_qq);size(20)" json:"contact_qq"` // 对接人QQ
|
||||
Address string `orm:"column(address);size(255)" json:"address"` // 公司地址
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化/3已关闭
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化/3已废弃
|
||||
Locked int8 `orm:"column(locked);default(0)" json:"locked"` // 0否/1是(转化后锁定不可编辑)
|
||||
BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 转化后的商机ID
|
||||
ConvertTime *time.Time `orm:"column(convert_time);type(datetime);null" json:"convert_time"` // 转化时间
|
||||
|
||||
@@ -13,8 +13,8 @@ import "time"
|
||||
type TenantCrmContract struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
ContractNo string `orm:"column(contract_no);size(50)" json:"contract_no"` // 合同编号
|
||||
ContractName string `orm:"column(contract_name);size(100)" json:"contract_name"` // 合同名称
|
||||
ContractNo string `orm:"column(contract_no);size(50)" json:"contract_no"` // 合同编号
|
||||
ContractName string `orm:"column(contract_name);size(100)" json:"contract_name"` // 合同名称
|
||||
ContractCategory string `orm:"column(contract_category);size(20)" json:"contract_category"` // 分类:1开发/2服务/3销售/4租赁/5采购/6运维/7咨询/8其他
|
||||
OurRole int8 `orm:"column(our_role);default(2)" json:"our_role"` // 我方角色:1甲方/2乙方/3丙方/4丁方
|
||||
PartyCount int8 `orm:"column(party_count);default(2)" json:"party_count"` // 合同形式:2/3/4 方
|
||||
@@ -33,7 +33,7 @@ type TenantCrmContract struct {
|
||||
TotalAmount float64 `orm:"column(total_amount);digits(14);decimals(2);default(0)" json:"total_amount"`
|
||||
TotalCost float64 `orm:"column(total_cost);digits(14);decimals(2);default(0)" json:"total_cost"`
|
||||
TotalProfit float64 `orm:"column(total_profit);digits(14);decimals(2);default(0)" json:"total_profit"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1草稿/2已完成/3已作废/4履约中/5执行异常
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1草稿/2已完成/3已作废/4履约中/5异常
|
||||
Step int8 `orm:"column(step);default(1)" json:"step"` // 进度式创建步骤:1合同信息/2产品清单
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
||||
|
||||
@@ -11,7 +11,9 @@ type TenantCrmEntityContact struct {
|
||||
RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
|
||||
ContactName string `orm:"column(contact_name);size(50)" json:"contact_name"`
|
||||
Position string `orm:"column(position);size(50)" json:"position"`
|
||||
Mobile string `orm:"column(mobile);size(20)" json:"mobile"`
|
||||
Mobile string `orm:"column(mobile);size(20)" json:"mobile"` // 手机号(保留第一个,兼容旧逻辑)
|
||||
// 手机号可登记多个(有的人有多个号码):JSON 数组,最多 5 个,mobile 始终与其中一个(第一个)保持一致
|
||||
Mobiles string `orm:"column(mobiles);type(text);null" json:"mobiles"`
|
||||
Wechat string `orm:"column(wechat);size(64)" json:"wechat"`
|
||||
QQ string `orm:"column(qq);size(20)" json:"qq"`
|
||||
Email string `orm:"column(email);size(100)" json:"email"`
|
||||
|
||||
@@ -9,12 +9,17 @@ import "time"
|
||||
// contract_id 冗余存储,便于按合同直接查询进度列表;
|
||||
// - 每次「新建回款」追加一条记录,构成回款进度(一笔笔实际到账),
|
||||
// 与计划内的 items(计划分期)区分开:前者是实际发生,后者是计划安排;
|
||||
// - 计划表 received_amount 由本表记录金额汇总回写(refreshPlanReceived)。
|
||||
// - 计划表 received_amount 由本表记录金额汇总回写(refreshPlanReceived);
|
||||
// - item_seq / item_name 标记本笔回款对应的计划明细节点(进度 / 分期):
|
||||
// item_seq 对应计划 items 中的 seq(1-based),0=未指定(按整单登记);
|
||||
// item_name 为节点名称快照,便于明细节点被改动后仍可追溯。
|
||||
type TenantCrmPaybackRecord struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
ContractID *uint64 `orm:"column(contract_id);null" json:"contract_id"` // 关联合同ID
|
||||
PaybackID uint64 `orm:"column(payback_id)" json:"payback_id"` // 关联回款计划ID
|
||||
ItemSeq int `orm:"column(item_seq);default(0)" json:"item_seq"` // 对应计划明细序号(1-based),0=未指定
|
||||
ItemName string `orm:"column(item_name);size(50)" json:"item_name"` // 计划明细节点名称快照
|
||||
Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"` // 本次回款金额
|
||||
ReceivedDate string `orm:"column(received_date);size(20)" json:"received_date"` // 回款日期 YYYY-MM-DD
|
||||
PayMethod int8 `orm:"column(pay_method);default(1)" json:"pay_method"` // 回款方式:1对公/2网银/3现金/4支票/5支付宝/6微信/7其他
|
||||
|
||||
@@ -23,6 +23,7 @@ type TenantCrmProduct struct {
|
||||
TaxRate float64 `orm:"column(tax_rate);digits(6);decimals(2);default(0)" json:"tax_rate"` // 税率(%)
|
||||
LineType string `orm:"column(line_type);size(20);default(product)" json:"line_type"` // 行类型 product/dev
|
||||
DevUnitPrice float64 `orm:"column(dev_unit_price);digits(14);decimals(2);default(0)" json:"dev_unit_price"` // 软件开发统一人天单价
|
||||
DevMode string `orm:"column(dev_mode);size(20);default(self)" json:"dev_mode"` // 软件开发研发方式:self自行研发/outsource外协研发(别人帮我们研发)
|
||||
DevTree string `orm:"column(dev_tree);type(text);null" json:"dev_tree"` // 软件开发模块树JSON文本
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1启用/0停用
|
||||
ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 来源项目ID(由项目生成时填入)
|
||||
|
||||
@@ -6,14 +6,16 @@ import "time"
|
||||
//
|
||||
// 说明:
|
||||
// - 与 CMS 产品分类类似,是 CRM 产品台账的产品归类(如:服务器类 / 软件许可 / 实施服务 等);
|
||||
// - 产品表单的「产品分类」下拉取自该表;status 1启用/0停用。
|
||||
// - 产品表单的「产品分类」下拉取自该表;status 1启用/0停用;
|
||||
// - is_default 1=系统默认分类(「未分类」,由系统确保存在,不可编辑、不可删除,排序置顶)/0=租户自定义分类(可增删改)。
|
||||
type TenantCrmProductCategory struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
Name string `orm:"column(name);size(100)" json:"name"` // 分类名称
|
||||
Code string `orm:"column(code);size(50)" json:"code"` // 分类编码(选填)
|
||||
Sort int `orm:"column(sort);default(0)" json:"sort"` // 排序
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1启用/0停用
|
||||
Name string `orm:"column(name);size(100)" json:"name"` // 分类名称
|
||||
Code string `orm:"column(code);size(50)" json:"code"` // 分类编码(选填)
|
||||
Sort int `orm:"column(sort);default(0)" json:"sort"` // 排序
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1启用/0停用
|
||||
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` // 1系统默认(不可编辑/删除)/0租户自定义
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
|
||||
@@ -16,18 +16,20 @@ type TenantCrmProject struct {
|
||||
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
|
||||
Industry string `orm:"column(industry);size(50)" json:"industry"`
|
||||
// 项目类型:1=单一项目(只有上游客户) 2=上下游项目(上游客户 + 下游供应商)
|
||||
// 3=上中游项目(上游 A 公司 + 中游 B 公司,下游固定为本公司)
|
||||
ProjectType int8 `orm:"column(project_type);default(1)" json:"project_type"`
|
||||
// 下游主体:可从供应商库或客户库选择(supplier_ref_type + supplier_id 标明来源),也允许手工填写名称
|
||||
// 对方主体(2=上下游项目的下游 / 3=上中游项目的中游):可从供应商库或客户库选择(supplier_ref_type + supplier_id 标明来源),也允许手工填写名称
|
||||
SupplierID *uint64 `orm:"column(supplier_id);null" json:"supplier_id"`
|
||||
SupplierRefType int8 `orm:"column(supplier_ref_type);default(0)" json:"supplier_ref_type"` // 0未关联 1客户 2供应商
|
||||
SupplierName string `orm:"column(supplier_name);size(100)" json:"supplier_name"`
|
||||
SupplierIndustry string `orm:"column(supplier_industry);size(50)" json:"supplier_industry"`
|
||||
// 金额拆分为上下游:上游=对客户的收入,下游=对供应商的成本(上下游项目毛利 = 上游 - 下游)
|
||||
// 金额拆分为上下游:上游=对客户的收入,下游=对供应商的成本(上下游项目毛利 = 上游 - 下游);
|
||||
// 上中游项目:上游=上游 A 公司合同额,下游字段存中游 B 公司(与我方)合同额
|
||||
UpstreamAmount float64 `orm:"column(upstream_amount);digits(14);decimals(2);default(0)" json:"upstream_amount"`
|
||||
DownstreamAmount float64 `orm:"column(downstream_amount);digits(14);decimals(2);default(0)" json:"downstream_amount"`
|
||||
// Amount 为旧「项目金额」列,已由 UpstreamAmount 取代;写入时始终同步为上游金额,仅作历史兼容
|
||||
Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1未开始/2进行中/3已完成/4已暂停
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1未开始/2进行中/3已完成/4已暂停/5异常
|
||||
// StartDate / EndDate 为旧的手工维护列,已改由关联合同自动汇总,保留仅用于兼容历史数据
|
||||
StartDate *time.Time `orm:"column(start_date);type(date);null" json:"start_date"`
|
||||
EndDate *time.Time `orm:"column(end_date);type(date);null" json:"end_date"`
|
||||
@@ -39,6 +41,12 @@ type TenantCrmProject struct {
|
||||
UpstreamPhone string `orm:"column(upstream_phone);size(20)" json:"upstream_phone"`
|
||||
DownstreamContact string `orm:"column(downstream_contact);size(50)" json:"downstream_contact"`
|
||||
DownstreamPhone string `orm:"column(downstream_phone);size(20)" json:"downstream_phone"`
|
||||
// 项目联系人:从对应企业通讯录中挑选的联系人ID集合(JSON 数组字符串,如 [1,5,8],空为未选)。
|
||||
// 上游=客户方(yz_backend_erp_contact.company_type=customer & company_id=customer_id);
|
||||
// 下游/中游=对方主体方(company_type 按 supplier_ref_type:1=customer 2=supplier,company_id=supplier_id)。
|
||||
// 单一项目只使用上游集合;联系人详情实时来自企业通讯录,此处仅存 ID 引用。
|
||||
UpstreamContactIDs string `orm:"column(upstream_contact_ids);type(text);null" json:"upstream_contact_ids"`
|
||||
DownstreamContactIDs string `orm:"column(downstream_contact_ids);type(text);null" json:"downstream_contact_ids"`
|
||||
// 以下为旧版单一对接人信息,已被上下游对接人取代,仅保留列以兼容历史数据
|
||||
ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"`
|
||||
ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位(已废弃)
|
||||
|
||||
@@ -7,15 +7,17 @@ import "time"
|
||||
// 说明:
|
||||
// - 维护产品、合同等业务使用的计量单位(套 / 台 / 个 / 人天 …);
|
||||
// - 在「CRM -> 基本设置 -> 单位设置」中维护,产品表单的「单位」下拉取自该表;
|
||||
// - is_default 1=系统默认单位(由系统写入,不可编辑、不可删除)/0=租户自定义单位(可增删改);
|
||||
// - status 1启用/0停用;同租户下单位名称唯一(uk_tenant_name)。
|
||||
type TenantCrmUnit struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
Name string `orm:"column(name);size(20)" json:"name"` // 单位名称
|
||||
Code string `orm:"column(code);size(50)" json:"code"` // 单位编码(选填)
|
||||
Sort int `orm:"column(sort);default(0)" json:"sort"` // 排序
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1启用/0停用
|
||||
Remark string `orm:"column(remark);size(255)" json:"remark"` // 备注
|
||||
Name string `orm:"column(name);size(20)" json:"name"` // 单位名称
|
||||
Code string `orm:"column(code);size(50)" json:"code"` // 单位编码(选填)
|
||||
Sort int `orm:"column(sort);default(0)" json:"sort"` // 排序
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1启用/0停用
|
||||
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` // 1系统默认(不可编辑/删除)/0租户自定义
|
||||
Remark string `orm:"column(remark);size(255)" json:"remark"` // 备注
|
||||
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
|
||||
@@ -371,6 +371,8 @@ func registerOrganizationRoutes(module string) {
|
||||
beego.Router("/backend/erp/customer", &controllers.BackendErpCustomerController{}, "post:Create")
|
||||
beego.Router("/backend/erp/customer/:id", &controllers.BackendErpCustomerController{}, "get:Detail;put:Update;delete:Delete")
|
||||
beego.Router("/backend/erp/customer/:id/status", &controllers.BackendErpCustomerController{}, "post:ToggleStatus")
|
||||
// 客户转供应商(把客户档案含联系人复制一套到供应商库,原客户保留)
|
||||
beego.Router("/backend/erp/customer/:id/convert-supplier", &controllers.BackendErpCustomerController{}, "post:ConvertToSupplier")
|
||||
|
||||
// CRM联系人(客户/供应商对接人)
|
||||
beego.Router("/backend/crm/contact/list", &controllers.BackendCrmContactController{}, "get:List")
|
||||
@@ -400,6 +402,10 @@ func registerOrganizationRoutes(module string) {
|
||||
beego.Router("/backend/crm/project/list", &controllers.BackendCrmProjectController{}, "get:List")
|
||||
beego.Router("/backend/crm/project", &controllers.BackendCrmProjectController{}, "post:Create")
|
||||
beego.Router("/backend/crm/project/:id", &controllers.BackendCrmProjectController{}, "get:Detail;put:Update;delete:Delete")
|
||||
// 项目状态流转:1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常
|
||||
beego.Router("/backend/crm/project/:id/status", &controllers.BackendCrmProjectController{}, "post:ChangeStatus")
|
||||
// 项目联系人:从对应企业通讯录中挑选(上游=客户方,下游/中游=对方主体方),支持多选
|
||||
beego.Router("/backend/crm/project/:id/contacts", &controllers.BackendCrmProjectController{}, "post:SaveContacts")
|
||||
|
||||
// CRM产品管理(产品台账/目录;可由项目生成时写入)
|
||||
beego.Router("/backend/crm/product/list", &controllers.BackendCrmProductController{}, "get:List")
|
||||
@@ -472,6 +478,8 @@ func registerOrganizationRoutes(module string) {
|
||||
beego.Router("/backend/erp/supplier", &controllers.BackendErpSupplierController{}, "post:Create")
|
||||
beego.Router("/backend/erp/supplier/:id", &controllers.BackendErpSupplierController{}, "get:Detail;put:Update;delete:Delete")
|
||||
beego.Router("/backend/erp/supplier/:id/status", &controllers.BackendErpSupplierController{}, "post:ToggleStatus")
|
||||
// 供应商转客户(把供应商档案含联系人复制一套到客户库,原供应商保留)
|
||||
beego.Router("/backend/erp/supplier/:id/convert-customer", &controllers.BackendErpSupplierController{}, "post:ConvertToCustomer")
|
||||
|
||||
// ERP账套管理
|
||||
beego.Router("/backend/erp/account-set/list", &controllers.BackendErpAccountSetController{}, "get:List")
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// ============================== 回款逾期联动 ==============================
|
||||
// 规则:回款计划存在「未收齐且已过计划回款日期」的明细(即逾期)时,
|
||||
// - 对应合同状态置为「异常」(5);已作废(3)合同不标记,已是异常(5)时幂等跳过;
|
||||
// - 合同关联的项目状态联动置为「异常」(5);已是异常(5)时幂等跳过。
|
||||
//
|
||||
// 已收齐判定与「新建回款」机制一致:
|
||||
// - 回款记录按 item_seq 汇总(当前机制);
|
||||
// - 明细行内 received_amount(历史「进度登记」机制)。
|
||||
//
|
||||
// 说明:回款补齐后不自动恢复状态(无法区分「逾期导致的异常」与人工标注),
|
||||
// 需要恢复时在「状态流转」中手动调整即可。
|
||||
|
||||
// paybackSchedulerItem 回款计划明细(items JSON 元素,仅取判定所需字段)。
|
||||
type paybackSchedulerItem struct {
|
||||
Seq int `json:"seq"`
|
||||
Amount float64 `json:"amount"`
|
||||
PlanDate string `json:"plan_date"`
|
||||
ReceivedAmount float64 `json:"received_amount"` // 历史「进度登记」机制写入的已回款
|
||||
}
|
||||
|
||||
// StartPaybackOverdueScheduler 启动回款逾期联动调度器:
|
||||
// 启动 30 秒后执行首次扫描(尽快让历史逾期数据生效),此后每 10 分钟扫描一次。
|
||||
func StartPaybackOverdueScheduler(stopChan chan struct{}) {
|
||||
go func() {
|
||||
select {
|
||||
case <-time.After(30 * time.Second):
|
||||
syncPaybackOverdueStatuses()
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
syncPaybackOverdueStatuses()
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// syncPaybackOverdueStatuses 扫描全部未收齐的回款计划并执行逾期联动,返回 (合同更新数, 项目更新数)。
|
||||
func syncPaybackOverdueStatuses() (int, int) {
|
||||
if models.Orm == nil {
|
||||
return 0, 0
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
// 未收齐的计划(status=2 已完成跳过;items 为空时无逾期概念)
|
||||
var plans []models.TenantCrmPayback
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("status__ne", 2).
|
||||
All(&plans, "id", "tenant_id", "contract_id", "items"); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
contractUpdated, projectUpdated := 0, 0
|
||||
now := time.Now()
|
||||
handledContracts := make(map[uint64]bool) // 同一合同只处理一次
|
||||
for _, plan := range plans {
|
||||
if plan.ContractID == nil || *plan.ContractID == 0 {
|
||||
continue
|
||||
}
|
||||
contractID := *plan.ContractID
|
||||
if handledContracts[contractID] {
|
||||
continue
|
||||
}
|
||||
if !paybackPlanOverdueFor(plan, today) {
|
||||
continue
|
||||
}
|
||||
handledContracts[contractID] = true
|
||||
cu, pu := applyContractOverdue(plan.TenantID, contractID, now)
|
||||
contractUpdated += cu
|
||||
projectUpdated += pu
|
||||
}
|
||||
return contractUpdated, projectUpdated
|
||||
}
|
||||
|
||||
// SyncPaybackOverdueForContract 立即检查指定合同的回款逾期联动
|
||||
// (供回款计划 / 回款记录变更后调用,让状态即时生效,无需等待定时扫描)。
|
||||
func SyncPaybackOverdueForContract(tenantID string, contractID uint64) {
|
||||
if models.Orm == nil || contractID == 0 {
|
||||
return
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var plans []models.TenantCrmPayback
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("contract_id", contractID).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("status__ne", 2).
|
||||
All(&plans, "id", "tenant_id", "contract_id", "items"); err != nil {
|
||||
return
|
||||
}
|
||||
for _, plan := range plans {
|
||||
if paybackPlanOverdueFor(plan, today) {
|
||||
applyContractOverdue(tenantID, contractID, time.Now())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paybackPlanOverdueFor 计划是否逾期:存在「未收齐」明细且计划回款日期已过。
|
||||
func paybackPlanOverdueFor(plan models.TenantCrmPayback, today string) bool {
|
||||
items := make([]paybackSchedulerItem, 0)
|
||||
if raw := strings.TrimSpace(plan.Items); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return false
|
||||
}
|
||||
// 回款记录按明细序号汇总(item_seq=0 视为整单登记,不计入任何明细)
|
||||
receivedBySeq := make(map[int]float64)
|
||||
var recs []models.TenantCrmPaybackRecord
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||
Filter("payback_id", plan.ID).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&recs, "item_seq", "amount")
|
||||
for _, r := range recs {
|
||||
if r.ItemSeq > 0 {
|
||||
receivedBySeq[r.ItemSeq] += r.Amount
|
||||
}
|
||||
}
|
||||
for _, it := range items {
|
||||
received := receivedBySeq[it.Seq]
|
||||
if it.ReceivedAmount > received {
|
||||
received = it.ReceivedAmount
|
||||
}
|
||||
if it.Amount > 0 && received >= it.Amount {
|
||||
continue // 已收齐
|
||||
}
|
||||
planDate := strings.TrimSpace(it.PlanDate)
|
||||
if len(planDate) > 10 {
|
||||
planDate = planDate[:10]
|
||||
}
|
||||
if planDate != "" && planDate < today {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyContractOverdue 合同置「异常」(5),并联动项目置「异常」(5)。
|
||||
// 返回 (合同是否更新, 项目是否更新)。
|
||||
func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (int, int) {
|
||||
var contract models.TenantCrmContract
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
|
||||
Filter("id", contractID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&contract); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
// 已作废合同不参与逾期提醒
|
||||
if contract.Status == 3 {
|
||||
return 0, 0
|
||||
}
|
||||
contractUpdated := 0
|
||||
if contract.Status != 5 {
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
|
||||
Filter("id", contractID).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"status": int8(5), "update_time": now}); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
contractUpdated = 1
|
||||
}
|
||||
// 合同关联项目联动置异常
|
||||
projectUpdated := 0
|
||||
if contract.ProjectID != nil && *contract.ProjectID > 0 {
|
||||
var proj models.TenantCrmProject
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
Filter("id", *contract.ProjectID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&proj); err == nil && proj.Status != 5 {
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
Filter("id", proj.ID).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"status": int8(5), "update_time": now}); err == nil {
|
||||
projectUpdated = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return contractUpdated, projectUpdated
|
||||
}
|
||||
Reference in New Issue
Block a user