diff --git a/backend/src/views/apps/crm/contract/components/DevModuleTree.vue b/backend/src/views/apps/crm/contract/components/DevModuleTree.vue index eb58f2d..cbf730b 100644 --- a/backend/src/views/apps/crm/contract/components/DevModuleTree.vue +++ b/backend/src/views/apps/crm/contract/components/DevModuleTree.vue @@ -11,13 +11,13 @@
添加顶级模块 - 叶子模块只填「人天」,人天单价统一填写;父级自动汇总;可任意层级向下拆分 +
统一人天单价(元 / 人天) - 开发总报价:¥{{ formatMoney(treeSellTotal(tree, unitPrice)) }} + 按人天合计:¥{{ formatMoney(autoTotal) }}
@@ -37,31 +37,60 @@ /> + + +
+
+ 开发总成本(元) + + + 按人天自动计算:¥{{ formatMoney(autoTotal) }}(合计 {{ treeManDays(tree) }} 人天) + + 恢复自动计算 +
+
+ 该金额将作为此行的「开发成本(成本单价)」带出到产品清单;对外销售单价可在产品清单中自行填写,两者差额即为毛利 +
+
diff --git a/backend/src/views/apps/crm/contract/components/utils.js b/backend/src/views/apps/crm/contract/components/utils.js index ea90a1d..509bfd4 100644 --- a/backend/src/views/apps/crm/contract/components/utils.js +++ b/backend/src/views/apps/crm/contract/components/utils.js @@ -6,15 +6,18 @@ * - 软件部分 = Σ 产品类别为「软件(许可)」的小计 * - 其他部分 = Σ 服务 / 开发 / 其他类小计 * - 合同总金额 = 硬件 + 软件 + 其他 - * - 产品总成本 = Σ (数量 × 成本单价) + * - 产品总成本 = Σ (数量 × 成本单价),软件开发行按其「开发总成本」计入 * - 合同总利润 = 合同总金额 - 产品总成本 + * + * 软件开发(开发类)行口径:模块报价中「开发总成本」(默认人天×统一单价,可手工改写) + * 作为该行的成本单价并计入总成本;对外金额取用户填写的销售单价,两者差额即为毛利。 * 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他(与后端一致)。 */ const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100; /** - * 软件开发模块树节点售价合计: + * 软件开发模块树按人天的金额汇总(即「开发总成本」的默认口径): * - 含子节点 → 汇总子节点; * - 叶子节点 → 人天 × 统一人天单价。 */ @@ -31,15 +34,53 @@ export function treeSellTotal(nodes, unitPrice = 0) { ); } -/** 单行金额:成品 = 数量×单价;软件开发 = 模块树售价合计(人天×统一人天单价) */ +/** + * 软件开发模块树叶子节点人天合计(父级不重复计,仅用于展示)。 + */ +export function treeManDays(nodes) { + const list = Array.isArray(nodes) ? nodes : []; + const sum = list.reduce((acc, n) => { + if (Array.isArray(n.children) && n.children.length) return acc + treeManDays(n.children); + return acc + (Number(n.man_days) || 0); + }, 0); + return Math.round(sum * 10) / 10; +} + +/** 单行数量:软件开发行固定按 1 个单位计(历史数据可能缺数量,兜底为 1) */ +function lineQty(row) { + if (row?.line_type === "dev") return Number(row?.quantity) || 1; + return Number(row?.quantity) || 0; +} + +/** + * 软件开发行的开发总成本(= 模块报价带出的总价): + * 优先取 dev_total_price(可手工改写),回退人天汇总,兼容历史数据。 + */ +export function devCost(row) { + const manual = Number(row?.dev_total_price) || 0; + if (manual > 0) return round2(manual); + return treeSellTotal(row?.dev_tree, row?.dev_unit_price); +} + +/** + * 软件开发行的销售单价:由用户自行填写; + * 未填写时依次回退「开发总成本 → 人天汇总」,避免历史/未填数据显示为 0。 + */ +export function devSellPrice(row) { + const p = Number(row?.price) || 0; + if (p > 0) return round2(p); + return devCost(row); +} + +/** 单行金额:成品 = 数量×单价;软件开发 = 数量×销售单价(销售单价默认由开发总成本带出,可自行填写) */ export function lineAmount(row) { - if (row?.line_type === "dev") return treeSellTotal(row.dev_tree, row.dev_unit_price); + if (row?.line_type === "dev") return round2(lineQty(row) * devSellPrice(row)); return round2((Number(row?.quantity) || 0) * (Number(row?.price) || 0)); } -/** 单行成本:成品 = 数量×成本单价;软件开发不计成本 */ +/** 单行成本:成品 = 数量×成本单价;软件开发 = 数量×开发总成本(模块报价带出) */ export function lineCost(row) { - if (row?.line_type === "dev") return 0; + if (row?.line_type === "dev") return round2(lineQty(row) * devCost(row)); return round2((Number(row?.quantity) || 0) * (Number(row?.cost_price) || 0)); } @@ -51,8 +92,8 @@ export function buildSummary(products) { let totalCost = 0; rows.forEach((row) => { if (row?.line_type === "dev") { - const amount = treeSellTotal(row.dev_tree, row.dev_unit_price); - software += amount; // 软件开发计入软件部分,不计成本 + software += lineAmount(row); // 软件开发计入软件部分,并按开发总成本计入产品成本 + totalCost += lineCost(row); return; } const qty = Number(row?.quantity) || 0; diff --git a/backend/src/views/apps/crm/project/components/detail_attach.vue b/backend/src/views/apps/crm/project/components/detail_attach.vue index 2837f77..d1bfd4c 100644 --- a/backend/src/views/apps/crm/project/components/detail_attach.vue +++ b/backend/src/views/apps/crm/project/components/detail_attach.vue @@ -324,6 +324,7 @@ async function reload() { } finally { loading.value = false; } + // 无论成功失败都回传数量,避免角标与实际列表不一致 emit("count", total.value); } diff --git a/go/controllers/backend_crm_contract.go b/go/controllers/backend_crm_contract.go index 704b8fd..0217bce 100644 --- a/go/controllers/backend_crm_contract.go +++ b/go/controllers/backend_crm_contract.go @@ -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"]) diff --git a/go/controllers/backend_crm_product.go b/go/controllers/backend_crm_product.go index 7458af2..f9702ae 100644 --- a/go/controllers/backend_crm_product.go +++ b/go/controllers/backend_crm_product.go @@ -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 输出 ""。 +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))