更新各项功能
This commit is contained in:
@@ -300,8 +300,12 @@ func (c *BackendCrmContractController) Create() {
|
||||
return
|
||||
}
|
||||
row.Products = products
|
||||
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
|
||||
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
|
||||
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行)
|
||||
projID := uint64(0)
|
||||
if row.ProjectID != nil {
|
||||
projID = *row.ProjectID
|
||||
}
|
||||
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), projID, row.ProjectName, json.RawMessage(products)); serr == nil {
|
||||
row.Products = string(synced)
|
||||
}
|
||||
applyContractAmounts(&row)
|
||||
@@ -397,15 +401,18 @@ func (c *BackendCrmContractController) Update() {
|
||||
return
|
||||
}
|
||||
row.Products = products
|
||||
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
|
||||
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
|
||||
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行)
|
||||
projID := uint64(0)
|
||||
if row.ProjectID != nil {
|
||||
projID = *row.ProjectID
|
||||
}
|
||||
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), projID, row.ProjectName, json.RawMessage(products)); serr == nil {
|
||||
row.Products = string(synced)
|
||||
}
|
||||
}
|
||||
applyContractAmounts(&row)
|
||||
if p.Status != 0 {
|
||||
row.Status = p.Status
|
||||
}
|
||||
// 状态不随合同编辑变更(避免编辑保存把已流转的状态打回草稿);
|
||||
// 签订/履约/作废等流转统一由「状态流转」接口 ChangeStatus 控制。
|
||||
if p.Step != 0 {
|
||||
row.Step = p.Step
|
||||
}
|
||||
@@ -606,12 +613,30 @@ func applyContractAmounts(row *models.TenantCrmContract) {
|
||||
}
|
||||
var hardware, software, other, cost float64
|
||||
for _, item := range items {
|
||||
// 软件开发行:金额来自模块树汇总(统一人天单价×人天,不计税率/成本,与前端 buildSummary 一致)
|
||||
// 软件开发行:开发总成本由模块报价带出(默认 = 人天×统一人天单价,可手工改写),计入产品成本;
|
||||
// 对外金额取销售单价(缺省时回退开发总成本 / 人天汇总,兼容历史数据);不计税率。
|
||||
// 口径与前端 utils.js 的 lineAmount / lineCost / buildSummary 保持一致。
|
||||
if fmt.Sprintf("%v", item["line_type"]) == "dev" {
|
||||
if tree, ok := item["dev_tree"].([]interface{}); ok {
|
||||
unitPrice := toFloat64(item["dev_unit_price"])
|
||||
software = round2(software + contractTreeSellTotal(tree, unitPrice))
|
||||
qty := toFloat64(item["quantity"])
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
total := toFloat64(item["dev_total_price"])
|
||||
if total <= 0 {
|
||||
if tree, ok := item["dev_tree"].([]interface{}); ok {
|
||||
total = contractTreeSellTotal(tree, toFloat64(item["dev_unit_price"]))
|
||||
}
|
||||
}
|
||||
costPrice := toFloat64(item["cost_price"])
|
||||
if costPrice <= 0 {
|
||||
costPrice = total
|
||||
}
|
||||
price := toFloat64(item["price"])
|
||||
if price <= 0 {
|
||||
price = total
|
||||
}
|
||||
software = round2(software + round2(qty*price))
|
||||
cost = round2(cost + round2(qty*costPrice))
|
||||
continue
|
||||
}
|
||||
qty := toFloat64(item["quantity"])
|
||||
|
||||
@@ -375,10 +375,13 @@ func SyncProjectProducts(tenantID string, projectID uint64, projectName string,
|
||||
// SyncContractProducts 把合同产品清单同步到产品管理(供合同保存时调用):
|
||||
// - 已关联 product_id 或名称命中现有产品:回填 product_id,并将成本单价强制取产品管理
|
||||
// (成本追溯产品管理;销售单价不回写,允许合同溢价);
|
||||
// - 名称未命中(产品管理中不存在):新建产品档案,销售单价/成本单价/规格/单位/分类/税率取自合同行。
|
||||
// - 名称未命中(产品管理中不存在):新建产品档案,销售单价/成本单价/规格/单位/分类/税率取自合同行;
|
||||
// 软件开发行(line_type=dev)同样建档,并写入统一人天单价与模块树,使该「开发包」可在产品管理中复用;
|
||||
// - 软件开发行已关联到产品档案时,回写最新模块树与人天单价(价格仍由产品管理主导,不回写);
|
||||
// - projectID 传入时回填为产品档案的「来源项目」。
|
||||
//
|
||||
// 返回(可能已回填 product_id 与成本单价)的清单 JSON,供合同落库。
|
||||
func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMessage, error) {
|
||||
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
|
||||
@@ -388,11 +391,11 @@ func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMe
|
||||
}
|
||||
now := time.Now()
|
||||
for _, it := range items {
|
||||
// 软件开发行(line_type=dev)不走产品管理建档,仅保留模块树
|
||||
if fmt.Sprintf("%v", it["line_type"]) == "dev" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(fmt.Sprintf("%v", it["name"]))
|
||||
// 行类型:成品 product / 软件开发 dev,缺省按成品处理并回填给清单
|
||||
lineType := normalizeLineType(productFieldStr(it["line_type"]))
|
||||
it["line_type"] = lineType
|
||||
|
||||
name := productFieldStr(it["name"])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
@@ -404,35 +407,60 @@ func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMe
|
||||
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 {
|
||||
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,
|
||||
Category: strings.TrimSpace(fmt.Sprintf("%v", it["category"])),
|
||||
Unit: strings.TrimSpace(fmt.Sprintf("%v", it["unit"])),
|
||||
Spec: strings.TrimSpace(fmt.Sprintf("%v", it["spec"])),
|
||||
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: strings.TrimSpace(fmt.Sprintf("%v", it["remark"])),
|
||||
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
|
||||
@@ -446,6 +474,61 @@ func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMe
|
||||
return json.RawMessage(out), nil
|
||||
}
|
||||
|
||||
// normalizeLineType 规范化行类型:空值按成品处理。
|
||||
func normalizeLineType(v string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return "product"
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// productFieldStr 安全取清单字段的字符串值:nil 时返回空串,避免 fmt 输出 "<nil>"。
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user