批量优化产品和产品分类

This commit is contained in:
2026-09-12 00:44:13 +08:00
parent 184c604bee
commit 2b1874943f
33 changed files with 2595 additions and 59 deletions
+25 -6
View File
@@ -81,6 +81,7 @@ func (c *BackendCrmContractController) List() {
ourRole := strings.TrimSpace(c.GetString("our_role"))
category := strings.TrimSpace(c.GetString("contract_category"))
projectType := strings.TrimSpace(c.GetString("project_type"))
projectID := strings.TrimSpace(c.GetString("project_id"))
status := strings.TrimSpace(c.GetString("status"))
tenantID := pipelineTenantID(claims)
@@ -110,6 +111,12 @@ func (c *BackendCrmContractController) List() {
case "headless": // 无头合同
cond = cond.And("project_id__isnull", true)
}
// 按项目精确筛选(项目详情「合同管理」Tab:一个项目可关联多份合同)
if projectID != "" {
if pid, err := strconv.ParseUint(projectID, 10, 64); err == nil && pid > 0 {
cond = cond.And("project_id", pid)
}
}
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
total, _ := qs.Count()
@@ -244,6 +251,10 @@ func (c *BackendCrmContractController) Create() {
return
}
row.Products = products
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
row.Products = string(synced)
}
applyContractAmounts(&row)
if _, err := models.Orm.Insert(&row); err != nil {
@@ -336,6 +347,10 @@ func (c *BackendCrmContractController) Update() {
return
}
row.Products = products
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
row.Products = string(synced)
}
}
applyContractAmounts(&row)
if p.Status != 0 {
@@ -512,8 +527,11 @@ func normalizeContractJSON(raw json.RawMessage) (string, error) {
}
// applyContractAmounts 按产品清单重算各部分金额(与前端 ProductList 汇总口径一致):
// 硬件部分=Σ硬件小计;软件部分=Σ软件小计;其他部分=Σ服务/开发/其他小计;
// 合同总金额=硬件+软件+其他;产品总成本=Σ(数量×成本单价);合同总利润=总金额-总成本。
// 每行含税金额=数量×单价×(1+税率/100);
// 硬件部分=Σ硬件含税小计;软件部分=Σ软件含税小计;其他部分=Σ服务/开发/其他含税小计;
// 合同总金额(含税)=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本通常不含税,原值汇总);
// 合同总利润=总金额-总成本。
// 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他。
func applyContractAmounts(row *models.TenantCrmContract) {
var items []map[string]interface{}
if strings.TrimSpace(row.Products) != "" {
@@ -524,13 +542,14 @@ func applyContractAmounts(row *models.TenantCrmContract) {
qty := toFloat64(item["quantity"])
price := toFloat64(item["price"])
costPrice := toFloat64(item["cost_price"])
amount := round2(qty * price)
taxRate := toFloat64(item["tax_rate"])
amount := round2(qty * price * (1 + taxRate/100)) // 含税金额
cost = round2(cost + round2(qty*costPrice))
cat := fmt.Sprintf("%v", item["category"])
switch cat {
case "1":
switch {
case cat == "1" || strings.Contains(cat, "硬件"):
hardware = round2(hardware + amount)
case "2":
case cat == "2" || strings.Contains(cat, "软件"):
software = round2(software + amount)
default:
other = round2(other + amount)
@@ -83,6 +83,10 @@ func parsePipelineDateTime(s string) *time.Time {
}
layouts := []string{
"2006-01-02 15:04:05",
// 带时区偏移的 RFC3339:前端编辑时回显的是后端 JSON 序列化的 time.Time
// (如 2026-09-12T00:00:00+08:00),缺此布局会解析失败导致日期被写空。
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
+431
View File
@@ -0,0 +1,431 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendCrmProductController CRM 产品管理(产品台账/目录)
type BackendCrmProductController struct {
beego.Controller
}
type crmProductPayload struct {
ProductNo string `json:"product_no"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Unit string `json:"unit"`
Spec string `json:"spec"`
Price float64 `json:"price"`
CostPrice float64 `json:"cost_price"`
TaxRate float64 `json:"tax_rate"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
// crmProductItem 项目产品清单中的单行(也用于项目生成时写入产品管理)。
type crmProductItem struct {
ProductNo string `json:"product_no"`
Name string `json:"name"`
Category string `json:"category"`
Spec string `json:"spec"`
Unit string `json:"unit"`
Quantity interface{} `json:"quantity"`
Price float64 `json:"price"`
CostPrice float64 `json:"cost_price"`
TaxRate float64 `json:"tax_rate"`
Remark string `json:"remark"`
}
// List GET /backend/crm/product/list
func (c *BackendCrmProductController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
keyword := strings.TrimSpace(c.GetString("keyword"))
category := strings.TrimSpace(c.GetString("category"))
status := strings.TrimSpace(c.GetString("status"))
projectID, _ := c.GetUint64("project_id")
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if keyword != "" {
kw := orm.NewCondition().
Or("product_name__contains", keyword).
Or("product_no__contains", keyword).
Or("spec__contains", keyword)
cond = cond.AndCond(kw)
}
if category != "" {
cond = cond.And("category", category)
}
if status != "" {
cond = cond.And("status", status)
}
if projectID > 0 {
cond = cond.And("project_id", projectID)
}
qs := models.Orm.QueryTable(new(models.TenantCrmProduct)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmProduct
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize,
})
}
// Detail GET /backend/crm/product/:id
func (c *BackendCrmProductController) Detail() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var p models.TenantCrmProduct
if err := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
Filter("delete_time__isnull", true).One(&p); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品未找到")
return
}
pipelineOk(&c.Controller, p)
}
// Create POST /backend/crm/product
func (c *BackendCrmProductController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p crmProductPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if strings.TrimSpace(p.ProductName) == "" {
pipelineErr(&c.Controller, 400, 400, "产品名称不能为空")
return
}
status := p.Status
if status != 0 && status != 1 {
status = 1
}
now := time.Now()
row := models.TenantCrmProduct{
TenantID: pipelineTenantID(claims),
ProductNo: strings.TrimSpace(p.ProductNo),
ProductName: strings.TrimSpace(p.ProductName),
Category: strings.TrimSpace(p.Category),
Unit: strings.TrimSpace(p.Unit),
Spec: strings.TrimSpace(p.Spec),
Price: p.Price,
CostPrice: p.CostPrice,
TaxRate: p.TaxRate,
Status: status,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Update PUT /backend/crm/product/:id
func (c *BackendCrmProductController) Update() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p crmProductPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if strings.TrimSpace(p.ProductName) == "" {
pipelineErr(&c.Controller, 400, 400, "产品名称不能为空")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmProduct
if err := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品未找到")
return
}
status := p.Status
if status != 0 && status != 1 {
status = row.Status
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(orm.Params{
"product_no": strings.TrimSpace(p.ProductNo),
"product_name": strings.TrimSpace(p.ProductName),
"category": strings.TrimSpace(p.Category),
"unit": strings.TrimSpace(p.Unit),
"spec": strings.TrimSpace(p.Spec),
"price": p.Price,
"cost_price": p.CostPrice,
"tax_rate": p.TaxRate,
"status": status,
"remark": p.Remark,
"update_time": now,
})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Delete DELETE /backend/crm/product/:id
func (c *BackendCrmProductController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmProduct
if err := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品未找到")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(orm.Params{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
pipelineOk(&c.Controller, nil)
}
// SyncFromProject POST /backend/crm/product/sync-from-project
// 项目生成(或保存)时,把项目产品清单里的产品参数写入产品管理(按 名称+分类 去重 upsert)。
func (c *BackendCrmProductController) SyncFromProject() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var body struct {
ProjectID uint64 `json:"project_id"`
ProjectName string `json:"project_name"`
Products []crmProductItem `json:"products"`
}
if err := json.Unmarshal(raw, &body); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if body.ProjectID == 0 {
pipelineErr(&c.Controller, 400, 400, "缺少项目ID")
return
}
created, updated, err := SyncProjectProducts(pipelineTenantID(claims), body.ProjectID, strings.TrimSpace(body.ProjectName), body.Products)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "同步失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"created": created, "updated": updated})
}
// SyncProjectProducts 把项目产品清单写入产品管理:按 产品名称+分类 在同一租户下去重。
// - 已存在:刷新单价/成本/税率/规格/单位/来源项目/备注;
// - 不存在:新建产品档案,登记来源项目。
// 返回新建数、更新数。
func SyncProjectProducts(tenantID string, projectID uint64, projectName string, items []crmProductItem) (created int, updated int, err error) {
if len(items) == 0 {
return 0, 0, nil
}
now := time.Now()
for _, it := range items {
name := strings.TrimSpace(it.Name)
if name == "" {
continue
}
cat := strings.TrimSpace(it.Category)
var exist models.TenantCrmProduct
e := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("tenant_id", tenantID).
Filter("product_name", name).
Filter("category", cat).
Filter("delete_time__isnull", true).
OrderBy("-id").Limit(1).One(&exist)
if e == nil && exist.ID > 0 {
// 已存在:更新参数
_, uerr := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", exist.ID).
Update(orm.Params{
"product_no": strings.TrimSpace(it.ProductNo),
"spec": strings.TrimSpace(it.Spec),
"unit": strings.TrimSpace(it.Unit),
"price": it.Price,
"cost_price": it.CostPrice,
"tax_rate": it.TaxRate,
"project_id": projectID,
"project_name": projectName,
"remark": it.Remark,
"update_time": now,
})
if uerr != nil {
err = uerr
return
}
updated++
continue
}
// 不存在:新建
row := models.TenantCrmProduct{
TenantID: tenantID,
ProductNo: strings.TrimSpace(it.ProductNo),
ProductName: name,
Category: cat,
Unit: strings.TrimSpace(it.Unit),
Spec: strings.TrimSpace(it.Spec),
Price: it.Price,
CostPrice: it.CostPrice,
TaxRate: it.TaxRate,
Status: 1,
ProjectID: &projectID,
ProjectName: projectName,
Remark: it.Remark,
CreateUserID: "",
CreateTime: now,
UpdateTime: now,
}
if _, ierr := models.Orm.Insert(&row); ierr != nil {
err = ierr
return
}
created++
}
return created, updated, nil
}
// SyncContractProducts 把合同产品清单同步到产品管理(供合同保存时调用):
// - 已关联 product_id 或名称命中现有产品:回填 product_id,并将成本单价强制取产品管理
// (成本追溯产品管理;销售单价不回写,允许合同溢价);
// - 名称未命中(产品管理中不存在):新建产品档案,销售单价/成本单价/规格/单位/分类/税率取自合同行。
//
// 返回(可能已回填 product_id 与成本单价)的清单 JSON,供合同落库。
func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMessage, error) {
var items []map[string]interface{}
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "[]" || strings.TrimSpace(string(raw)) == "" {
return json.RawMessage("[]"), nil
}
if err := json.Unmarshal(raw, &items); err != nil {
return raw, err
}
now := time.Now()
for _, it := range items {
name := strings.TrimSpace(fmt.Sprintf("%v", it["name"]))
if name == "" {
continue
}
// 已关联产品:成本单价强制取产品管理,不回写销售单价
if pid := toUint64(it["product_id"]); pid > 0 {
var p models.TenantCrmProduct
if e := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", pid).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&p); e == nil && p.ID > 0 {
it["product_id"] = p.ID
it["cost_price"] = p.CostPrice
}
continue
}
// 按名称命中现有产品(未启用也命中,便于回填)
var exist models.TenantCrmProduct
e := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("tenant_id", tenantID).Filter("product_name", name).
Filter("delete_time__isnull", true).OrderBy("-id").Limit(1).One(&exist)
if e == nil && exist.ID > 0 {
it["product_id"] = exist.ID
it["cost_price"] = exist.CostPrice
continue
}
// 未命中:新建产品档案(成本/单价等取自合同行)
row := models.TenantCrmProduct{
TenantID: tenantID,
ProductName: name,
Category: strings.TrimSpace(fmt.Sprintf("%v", it["category"])),
Unit: strings.TrimSpace(fmt.Sprintf("%v", it["unit"])),
Spec: strings.TrimSpace(fmt.Sprintf("%v", it["spec"])),
Price: toFloat64(it["price"]),
CostPrice: toFloat64(it["cost_price"]),
TaxRate: toFloat64(it["tax_rate"]),
Status: 1,
Remark: strings.TrimSpace(fmt.Sprintf("%v", it["remark"])),
CreateUserID: uid,
CreateTime: now,
UpdateTime: now,
}
id, ierr := models.Orm.Insert(&row)
if ierr != nil {
return raw, ierr
}
it["product_id"] = uint64(id)
}
out, err := json.Marshal(items)
if err != nil {
return raw, err
}
return json.RawMessage(out), nil
}
// toUint64 转为 uint64(依赖同包 toInt64)。
func toUint64(v interface{}) uint64 {
return uint64(toInt64(v))
}
@@ -0,0 +1,223 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendCrmProductCategoryController CRM 产品分类(用户自定义产品归类)
type BackendCrmProductCategoryController struct {
beego.Controller
}
type crmProductCategoryPayload struct {
Name string `json:"name"`
Code string `json:"code"`
Sort int `json:"sort"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
// List GET /backend/crm/product/category/list
func (c *BackendCrmProductCategoryController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 20
}
keyword := strings.TrimSpace(c.GetString("keyword"))
status := strings.TrimSpace(c.GetString("status"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if keyword != "" {
kw := orm.NewCondition().
Or("name__contains", keyword).
Or("code__contains", keyword)
cond = cond.AndCond(kw)
}
if status != "" {
cond = cond.And("status", status)
}
qs := models.Orm.QueryTable(new(models.TenantCrmProductCategory)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmProductCategory
if total > 0 {
_, _ = qs.OrderBy("sort", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize,
})
}
// Detail GET /backend/crm/product/category/:id
func (c *BackendCrmProductCategoryController) Detail() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var row models.TenantCrmProductCategory
if err := models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品分类未找到")
return
}
pipelineOk(&c.Controller, row)
}
// Create POST /backend/crm/product/category
func (c *BackendCrmProductCategoryController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p crmProductCategoryPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
name := strings.TrimSpace(p.Name)
if name == "" {
pipelineErr(&c.Controller, 400, 400, "分类名称不能为空")
return
}
status := p.Status
if status != 0 && status != 1 {
status = 1
}
now := time.Now()
row := models.TenantCrmProductCategory{
TenantID: pipelineTenantID(claims),
Name: name,
Code: strings.TrimSpace(p.Code),
Sort: p.Sort,
Status: status,
Remark: p.Remark,
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
if strings.Contains(err.Error(), "uk_tenant_name") {
pipelineErr(&c.Controller, 400, 400, "分类名称已存在")
return
}
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Update PUT /backend/crm/product/category/:id
func (c *BackendCrmProductCategoryController) Update() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p crmProductCategoryPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
name := strings.TrimSpace(p.Name)
if name == "" {
pipelineErr(&c.Controller, 400, 400, "分类名称不能为空")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmProductCategory
if err := models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品分类未找到")
return
}
status := p.Status
if status != 0 && status != 1 {
status = row.Status
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(orm.Params{
"name": name,
"code": strings.TrimSpace(p.Code),
"sort": p.Sort,
"status": status,
"remark": p.Remark,
"update_time": now,
})
if err != nil {
if strings.Contains(err.Error(), "uk_tenant_name") {
pipelineErr(&c.Controller, 400, 400, "分类名称已存在")
return
}
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Delete DELETE /backend/crm/product/category/:id
func (c *BackendCrmProductCategoryController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmProductCategory
if err := models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品分类未找到")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(orm.Params{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
pipelineOk(&c.Controller, nil)
}
+20
View File
@@ -38,6 +38,7 @@ type projectPayload struct {
ContactPhone string `json:"contact_phone"`
Address string `json:"address"`
Remark string `json:"remark"`
Products string `json:"products"` // 项目产品清单JSON(生成时写入产品管理)
}
// List GET /backend/crm/project/list
@@ -173,6 +174,7 @@ func (c *BackendCrmProjectController) Create() {
ContactPhone: strings.TrimSpace(p.ContactPhone),
Address: strings.TrimSpace(p.Address),
Remark: p.Remark,
Products: p.Products,
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
@@ -182,6 +184,16 @@ func (c *BackendCrmProjectController) Create() {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
// 项目生成时,把产品清单里的产品参数写入产品管理(按 名称+分类 去重)
if strings.TrimSpace(p.Products) != "" {
var items []crmProductItem
if json.Unmarshal([]byte(p.Products), &items) == nil {
if _, _, serr := SyncProjectProducts(tenantID, uint64(id), proj.ProjectName, items); serr != nil {
// 同步失败不影响项目创建,仅记录
_ = serr
}
}
}
// 项目建立后,同步在文档库「共享文档 / 项目文档」下建立同名文件夹(失败不影响项目创建)
if cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, uint64(id), proj.ProjectName, 0); err == nil {
proj.DocCategoryID = cid
@@ -253,12 +265,20 @@ func (c *BackendCrmProjectController) Update() {
proj.ContactPhone = strings.TrimSpace(p.ContactPhone)
proj.Address = strings.TrimSpace(p.Address)
proj.Remark = p.Remark
proj.Products = p.Products
proj.UpdateTime = time.Now()
if _, err := models.Orm.Update(&proj); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
// 项目保存时,把产品清单里的产品参数写入产品管理(按 名称+分类 去重)
if strings.TrimSpace(p.Products) != "" {
var items []crmProductItem
if json.Unmarshal([]byte(p.Products), &items) == nil {
_, _, _ = SyncProjectProducts(tenantID, proj.ID, proj.ProjectName, items)
}
}
// 项目改名时同步重命名文档库中的项目文件夹(失败静默,不影响项目更新)
if proj.DocCategoryID > 0 {
_ = services.RenameCrmProjectDocCategory(claims.TenantId, proj.DocCategoryID, proj.ProjectName)
@@ -0,0 +1,15 @@
-- 项目表新增「产品清单」字段:yz_backend_crm_project.products
-- 手动执行;可重复执行(列已存在时自动跳过,不再报 1060)。
-- 该字段用于保存项目生成产品时写入产品管理的产品清单JSON。
SET @dbname = DATABASE();
SET @tablename = 'yz_backend_crm_project';
SET @columnname = 'products';
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
'SELECT 1',
'ALTER TABLE yz_backend_crm_project ADD COLUMN products text COMMENT ''项目产品清单JSON(写入产品管理时使用)'''
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
+29
View File
@@ -0,0 +1,29 @@
-- CRM 产品管理表:yz_backend_crm_product
-- 手动执行(可重复执行);产品可由「项目生成」时自动写入,也可在「产品管理」中独立维护。
CREATE TABLE IF NOT EXISTS yz_backend_crm_product (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
product_no varchar(50) NOT NULL DEFAULT '' COMMENT '产品编号',
product_name varchar(100) NOT NULL COMMENT '产品名称',
category varchar(20) NOT NULL DEFAULT '' COMMENT '分类:1硬件/2软件/3服务/4开发/5其他',
unit varchar(20) NOT NULL DEFAULT '' COMMENT '单位',
spec varchar(255) NOT NULL DEFAULT '' COMMENT '规格型号',
price decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '销售单价',
cost_price decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '成本单价',
tax_rate decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '税率(%)',
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用',
project_id bigint(20) DEFAULT NULL COMMENT '来源项目ID(由项目生成时填入)',
project_name varchar(100) NOT NULL DEFAULT '' COMMENT '来源项目名称',
remark text COMMENT '备注',
create_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (id),
KEY idx_tenant_id (tenant_id),
KEY idx_product_name (tenant_id,product_name),
KEY idx_category (tenant_id,category),
KEY idx_project (tenant_id,project_id),
KEY idx_status (tenant_id,status),
KEY idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM产品管理表';
@@ -0,0 +1,19 @@
-- CRM 产品分类表:yz_backend_crm_product_category
-- 手动执行(可重复执行);建表后请在「产品管理-产品分类」中维护数据。
CREATE TABLE IF NOT EXISTS yz_backend_crm_product_category (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
name varchar(100) NOT NULL COMMENT '分类名称',
code varchar(50) NOT NULL DEFAULT '' COMMENT '分类编码(选填)',
sort int(11) NOT NULL DEFAULT '0' COMMENT '排序',
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用',
remark text COMMENT '备注',
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_name (tenant_id,name),
KEY idx_tenant_id (tenant_id),
KEY idx_status (tenant_id,status),
KEY idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM产品分类表';
+13
View File
@@ -77,6 +77,8 @@ func Init(_ string) {
new(TenantCrmClue),
new(TenantCrmBusiness),
new(TenantCrmProject),
new(TenantCrmProduct),
new(TenantCrmProductCategory),
new(TenantCrmFollow),
new(TenantCrmAttach),
new(TenantCrmEntityContact),
@@ -145,6 +147,7 @@ func Init(_ string) {
EnsureCrmProjectDocColumn()
EnsureCrmContractTable()
EnsureCrmContractOurRoleColumn()
EnsureCrmProjectProductsColumn()
}
// EnsureCrmContractOurRoleColumn 补齐合同表的我方角色字段(存量表已建时新增;
@@ -155,6 +158,14 @@ func EnsureCrmContractOurRoleColumn() {
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmProjectProductsColumn 补齐项目表的产品清单字段(存量表已建时新增;
// 表不存在或列已存在时忽略错误,可用 docs/sql/alter_backend_crm_project_products_column.sql 手动修复)。
func EnsureCrmProjectProductsColumn() {
sql := "ALTER TABLE " + new(TenantCrmProject).TableName() +
" ADD COLUMN products text COMMENT '项目产品清单JSON(写入产品管理时使用)'"
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmContractTable 合同表建表(CREATE TABLE IF NOT EXISTS,可重复执行;
// 建表失败(如表已存在但结构不一致)时静默忽略,可用 sql/yz_backend_crm_contract.sql 手动修复)。
func EnsureCrmContractTable() {
@@ -211,6 +222,8 @@ func EnsureCrmProjectDocColumn() {
}
// EnsureCrmCreateUserColumn 补齐客户/供应商的创建人字段(用于删除权限校验)。
// 同样兼容历史表名差异,表不存在或列已存在时忽略错误。
func EnsureCrmCreateUserColumn() {
+36
View File
@@ -0,0 +1,36 @@
package models
import "time"
// TenantCrmProduct CRM 产品管理(产品台账/目录): yz_backend_crm_product
//
// 说明:
// - 产品可独立维护,也可由「项目生成」时自动写入(project_id 记录来源项目);
// - category 复用合同产品分类:1硬件/2软件/3服务/4开发/5其他;
// - price 为销售单价,cost_price 为成本单价,tax_rate 为税率(%)。
type TenantCrmProduct struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
ProductNo string `orm:"column(product_no);size(50)" json:"product_no"` // 产品编号
ProductName string `orm:"column(product_name);size(100)" json:"product_name"` // 产品名称
Category string `orm:"column(category);size(20)" json:"category"` // 分类 1硬件/2软件/3服务/4开发/5其他
Unit string `orm:"column(unit);size(20)" json:"unit"` // 单位
Spec string `orm:"column(spec);size(255)" json:"spec"` // 规格型号
Price float64 `orm:"column(price);digits(14);decimals(2);default(0)" json:"price"` // 销售单价
CostPrice float64 `orm:"column(cost_price);digits(14);decimals(2);default(0)" json:"cost_price"` // 成本单价
TaxRate float64 `orm:"column(tax_rate);digits(6);decimals(2);default(0)" json:"tax_rate"` // 税率(%)
Status int8 `orm:"column(status);default(1)" json:"status"` // 1启用/0停用
ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 来源项目ID(由项目生成时填入)
ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 来源项目名称
Remark string `orm:"column(remark);type(text);null" 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"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *TenantCrmProduct) TableName() string {
return "yz_backend_crm_product"
}
+27
View File
@@ -0,0 +1,27 @@
package models
import "time"
// TenantCrmProductCategory CRM 产品分类(用户自定义产品归类): yz_backend_crm_product_category
//
// 说明:
// - 与 CMS 产品分类类似,是 CRM 产品台账的产品归类(如:服务器类 / 软件许可 / 实施服务 等);
// - 产品表单的「产品分类」下拉取自该表;status 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停用
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"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *TenantCrmProductCategory) TableName() string {
return "yz_backend_crm_product_category"
}
+1
View File
@@ -27,6 +27,7 @@ type TenantCrmProject struct {
// DocCategoryID 项目在 OA 文档库的文件夹分类ID:
// 指向 yz_backend_oa_doc_category 中「共享文档 / 项目文档 / {项目名称}」这条记录(0 表示尚未建立)。
DocCategoryID uint64 `orm:"column(doc_category_id);default(0)" json:"doc_category_id"`
Products string `orm:"column(products);type(text);null" json:"products"` // 项目产品清单JSON(写入产品管理时使用)
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"`
+11
View File
@@ -401,6 +401,17 @@ func registerOrganizationRoutes(module string) {
beego.Router("/backend/crm/project", &controllers.BackendCrmProjectController{}, "post:Create")
beego.Router("/backend/crm/project/:id", &controllers.BackendCrmProjectController{}, "get:Detail;put:Update;delete:Delete")
// CRM产品管理(产品台账/目录;可由项目生成时写入)
beego.Router("/backend/crm/product/list", &controllers.BackendCrmProductController{}, "get:List")
beego.Router("/backend/crm/product", &controllers.BackendCrmProductController{}, "post:Create")
beego.Router("/backend/crm/product/:id", &controllers.BackendCrmProductController{}, "get:Detail;put:Update;delete:Delete")
beego.Router("/backend/crm/product/sync-from-project", &controllers.BackendCrmProductController{}, "post:SyncFromProject")
// CRM产品分类管理(用户自定义产品归类;产品表单的分类下拉取自该表)
beego.Router("/backend/crm/product/category/list", &controllers.BackendCrmProductCategoryController{}, "get:List")
beego.Router("/backend/crm/product/category", &controllers.BackendCrmProductCategoryController{}, "post:Create")
beego.Router("/backend/crm/product/category/:id", &controllers.BackendCrmProductCategoryController{}, "get:Detail;put:Update;delete:Delete")
// CRM项目文档(与OA文档库「共享文档/项目文档」绑定:列表/上传同步/删除同步/文件夹管理)
beego.Router("/backend/crm/project/:id/docs", &controllers.BackendCrmProjectController{}, "get:DocList")
beego.Router("/backend/crm/project/:id/doc-upload", &controllers.BackendCrmProjectController{}, "post:DocUpload")