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"` LineType string `json:"line_type"` DevUnitPrice float64 `json:"dev_unit_price"` DevTree json.RawMessage `json:"dev_tree"` 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 } lineType := strings.TrimSpace(p.LineType) if lineType != "dev" { lineType = "product" } 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, LineType: lineType, DevUnitPrice: p.DevUnitPrice, DevTree: string(p.DevTree), 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 } lineType := strings.TrimSpace(p.LineType) if lineType != "dev" { lineType = "product" } 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, "line_type": lineType, "dev_unit_price": p.DevUnitPrice, "dev_tree": p.DevTree, "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,并将成本单价强制取产品管理 // (成本追溯产品管理;销售单价不回写,允许合同溢价); // - 名称未命中(产品管理中不存在):新建产品档案,销售单价/成本单价/规格/单位/分类/税率取自合同行; // 软件开发行(line_type=dev)同样建档,并写入统一人天单价与模块树,使该「开发包」可在产品管理中复用; // - 软件开发行已关联到产品档案时,回写最新模块树与人天单价(价格仍由产品管理主导,不回写); // - projectID 传入时回填为产品档案的「来源项目」。 // // 返回(可能已回填 product_id 与成本单价)的清单 JSON,供合同落库。 func SyncContractProducts(tenantID, uid string, projectID uint64, projectName 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 { // 行类型:成品 product / 软件开发 dev,缺省按成品处理并回填给清单 lineType := normalizeLineType(productFieldStr(it["line_type"])) it["line_type"] = lineType name := productFieldStr(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 syncDevTreeToProduct(&p, it) } 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 && normalizeLineType(exist.LineType) == lineType { it["product_id"] = exist.ID it["cost_price"] = exist.CostPrice syncDevTreeToProduct(&exist, it) continue } // 未命中:新建产品档案(成本/单价等取自合同行) row := models.TenantCrmProduct{ TenantID: tenantID, ProductNo: genProductNo(tenantID), ProductName: name, LineType: lineType, Category: productFieldStr(it["category"]), Unit: productFieldStr(it["unit"]), Spec: productFieldStr(it["spec"]), Price: toFloat64(it["price"]), CostPrice: toFloat64(it["cost_price"]), TaxRate: toFloat64(it["tax_rate"]), Status: 1, Remark: productFieldStr(it["remark"]), CreateUserID: uid, CreateTime: now, UpdateTime: now, } // 软件开发行:写入统一人天单价与模块树,作为可复用的「开发包」 if lineType == "dev" { row.DevUnitPrice = toFloat64(it["dev_unit_price"]) if tree := it["dev_tree"]; tree != nil { if b, me := json.Marshal(tree); me == nil { row.DevTree = string(b) } } if row.Category == "" { row.Category = "软件开发" } if row.Unit == "" { row.Unit = "项" } } // 回填来源项目(仅项目合同有,无头合同为空) if projectID > 0 { pid := projectID row.ProjectID = &pid row.ProjectName = strings.TrimSpace(projectName) } 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 } // normalizeLineType 规范化行类型:空值按成品处理。 func normalizeLineType(v string) string { if strings.TrimSpace(v) == "" { return "product" } return strings.TrimSpace(v) } // productFieldStr 安全取清单字段的字符串值:nil 时返回空串,避免 fmt 输出 ""。 func productFieldStr(v interface{}) string { if v == nil { return "" } return strings.TrimSpace(fmt.Sprintf("%v", v)) } // syncDevTreeToProduct 软件开发行引用到产品档案时,回写最新模块树与人天单价 // (仅在有变化时更新;销售/成本单价不回写,仍由产品管理主导), // 保证后续合同再选该产品能拿到最新的开发包。 func syncDevTreeToProduct(p *models.TenantCrmProduct, it map[string]interface{}) { if p == nil || p.ID == 0 || normalizeLineType(p.LineType) != "dev" { return } tree := it["dev_tree"] if tree == nil { return } b, me := json.Marshal(tree) if me != nil { return } newTree := string(b) newUnitPrice := toFloat64(it["dev_unit_price"]) if newTree == p.DevTree && newUnitPrice == p.DevUnitPrice { return } p.DevTree = newTree p.DevUnitPrice = newUnitPrice _, _ = models.Orm.Update(p, "DevTree", "DevUnitPrice") } // genProductNo 生成产品编号 P-yyyymmdd-4位序号,并保证本租户内不重复。 func genProductNo(tenantID string) string { day := time.Now().Format("20060102") for i := 0; i < 8; i++ { no := fmt.Sprintf("P-%s-%04d", day, (time.Now().UnixNano()+int64(i)*37)%10000) cnt, err := models.Orm.QueryTable(new(models.TenantCrmProduct)). Filter("tenant_id", tenantID).Filter("product_no", no).Count() if err == nil && cnt == 0 { return no } } return fmt.Sprintf("P-%s-%d", day, time.Now().UnixNano()%100000) } // toUint64 转为 uint64(依赖同包 toInt64)。 func toUint64(v interface{}) uint64 { return uint64(toInt64(v)) }