From aea53e076681bec4ab8b820925a5f67158a9fc6a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com>
Date: Sun, 13 Sep 2026 01:29:08 +0800
Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=90=84=E9=A1=B9=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../crm/contract/components/DevModuleTree.vue | 88 ++++-
.../crm/contract/components/ProductList.vue | 103 +++++-
.../apps/crm/contract/components/create.vue | 57 ++--
.../apps/crm/contract/components/detail.vue | 38 ++-
.../crm/contract/components/detail_basic.vue | 51 ++-
.../contract/components/detail_payback.vue | 307 +++++++++++++++++-
.../apps/crm/contract/components/utils.js | 57 +++-
.../crm/project/components/detail_attach.vue | 1 +
go/controllers/backend_crm_contract.go | 47 ++-
go/controllers/backend_crm_product.go | 109 ++++++-
10 files changed, 763 insertions(+), 95 deletions(-)
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))