- {{ c.contract_name }}
-
-
- {{ ourRoleText(c.our_role) }}
+
+ {{ c.contract_name }}
+
+
+ {{ contractSideOf(c) === "down" ? "下游" : "上游" }}
-
- {{ c.contract_no || "-" }} · {{ contractCategoryText(c.contract_category) }}
-
-
+
基础信息
+ {{ c.contract_no || "-" }}
+ {{ contractCategoryText(c.contract_category) }}
{{ formatDateOnly(c.sign_date) }}
{{ c.owner_user_name || "-" }}
{{ formatDateOnly(c.effective_date) }}
@@ -154,15 +156,14 @@ import { ref, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { getContractList, deleteContract } from "@/api/crmContract";
+import { useRouter } from "vue-router";
+import { useMenuStore } from "@/stores/menu";
import ContractCreate from "../../contract/components/create.vue";
import { PARTY_ROLES, buildSummary } from "../../contract/components/utils";
import {
formatMoney,
formatDateOnly,
- ourRoleText,
contractCategoryText,
- contractStatusText,
- contractStatusTag,
productCategoryText,
productCategoryTag,
} from "../../dict";
@@ -175,6 +176,9 @@ const props = defineProps({
});
const emit = defineEmits(["count"]);
+const router = useRouter();
+const menuStore = useMenuStore();
+
const loading = ref(false);
const list = ref([]);
const activeId = ref("");
@@ -190,6 +194,18 @@ const presetProject = computed(() =>
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
+/**
+ * 合同在项目中的上下游归属:
+ * 判定以「我方是收款方还是付款方」为准 —— 参与方中出现供应商(我方下游)即下游合同;
+ * 其余按我方角色判断:我方为甲方(采购 / 付款方)算下游,为乙方(供货 / 收款方)算上游。
+ * 注意:不能只看对方是否在客户库建档,客户也可能是我方的上游供应商。
+ */
+function contractSideOf(c) {
+ const parties = Array.isArray(c?.parties) ? c.parties : [];
+ if (parties.some((p) => Number(p?.ref_type) === 2)) return "down";
+ return Number(c?.our_role) === 1 ? "down" : "up";
+}
+
/** 金额汇总:优先后端 summary,缺失时按产品清单前端重算 */
function summaryOf(c) {
const s = c?.summary;
@@ -220,6 +236,34 @@ async function reload() {
emit("count", list.value.length);
}
+/**
+ * 查找「合同管理」菜单路径:按菜单中配置的组件路径定位,
+ * 避免硬编码(菜单路径可由管理员调整,且需当前用户有该菜单权限)。
+ */
+function findContractManagePath() {
+ const walk = (nodes) => {
+ for (const m of nodes || []) {
+ if (String(m.component_path || "").includes("crm/contract/index")) return m.path;
+ if (m.children?.length) {
+ const hit = walk(m.children);
+ if (hit) return hit;
+ }
+ }
+ return "";
+ };
+ return walk(menuStore.menus) || "";
+}
+
+/** 点击合同名称:跳转「合同管理」并自动打开该合同详情 */
+function goContract(c) {
+ const path = findContractManagePath();
+ if (!path) {
+ ElMessage.warning("未找到「合同管理」菜单入口,请联系管理员配置");
+ return;
+ }
+ router.push({ path, query: { id: c.id } });
+}
+
function handleCreate() {
editRow.value = null;
initStep.value = 1;
@@ -312,18 +356,22 @@ defineExpose({ reload });
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
+
+ /* 可点击:跳转合同管理查看完整详情 */
+ &.link {
+ color: var(--el-color-primary);
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
}
}
.pane-actions {
flex-shrink: 0;
}
-
- .pane-sub {
- margin-top: 6px;
- font-size: 13px;
- color: var(--el-text-color-secondary);
- }
}
.summary-grid {
diff --git a/backend/src/views/apps/crm/project/components/edit.vue b/backend/src/views/apps/crm/project/components/edit.vue
index 12416c7..06f62b3 100644
--- a/backend/src/views/apps/crm/project/components/edit.vue
+++ b/backend/src/views/apps/crm/project/components/edit.vue
@@ -8,6 +8,15 @@
@opened="handleOpened"
>
+
+
+
+ 单一项目
+ 上下游项目
+
+
+
+ 项目概况
@@ -19,6 +28,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ¥{{ formatMoney(profitAmount) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isBackToBack ? "上游信息" : "客户信息" }}
+
-
-
-
-
-
-
-
+
+
元
-
-
-
+
+
+
+
+ {{ c.contact_name }}
+
+ {{ [c.position || c.department, c.mobile || c.phone].filter(Boolean).join(" · ") }}
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ 下游信息
+
+
+
+ applySupplier(null)"
+ >
+
+ {{ item.supplier_name }}
+
+ {{ Number(item.ref_type) === 1 ? "客户" : "供应商" }}
+
+
+
+
+
+
+
+
+ 元
+
+
+
+
+
+
+
+
+ {{ c.contact_name }}
+
+ {{ [c.position || c.department, c.mobile || c.phone].filter(Boolean).join(" · ") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.customer_name || "-" }} / {{ row.supplier_name || "-" }}
+
+ {{ row.customer_name || "-" }}
+
+
+
@@ -61,17 +81,17 @@
- {{ formatDateOnly(row.start_date) }}
+ {{ formatDateOnly(row.contract_start_date || row.start_date) }}
- {{ formatDateOnly(row.end_date) }}
+ {{ formatDateOnly(row.contract_end_date || row.end_date) }}
{{ row.owner_user_name || "-" }}
-
- {{ row.contact_person || "-" }}
-
+
diff --git a/go/controllers/backend_crm_business.go b/go/controllers/backend_crm_business.go
index 8aa1d7d..24027d4 100644
--- a/go/controllers/backend_crm_business.go
+++ b/go/controllers/backend_crm_business.go
@@ -64,6 +64,8 @@ func (c *BackendCrmBusinessController) List() {
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
+ cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
kw := orm.NewCondition().
Or("business_name__contains", keyword).
@@ -195,6 +197,11 @@ func (c *BackendCrmBusinessController) Create() {
CreateTime: now,
UpdateTime: now,
}
+ // 唯一性校验:同租户内不允许重复创建同名商机(重复数据请联系管理员转移跟进)
+ if crmNameDuplicated(new(models.TenantCrmBusiness), tenantID, "business_name", biz.BusinessName) {
+ pipelineErr(&c.Controller, 409, 409, "该商机名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
+ return
+ }
id, err := models.Orm.Insert(&biz)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
@@ -230,6 +237,11 @@ func (c *BackendCrmBusinessController) Update() {
pipelineErr(&c.Controller, 404, 404, "商机未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
+ return
+ }
if biz.Locked == 1 {
pipelineErr(&c.Controller, 400, 400, "商机已转为项目并锁定,不能编辑")
return
@@ -306,6 +318,11 @@ func (c *BackendCrmBusinessController) Delete() {
pipelineErr(&c.Controller, 404, 404, "商机未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
+ return
+ }
if biz.Locked == 1 || biz.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能删除")
return
@@ -359,6 +376,11 @@ func (c *BackendCrmBusinessController) Convert() {
pipelineErr(&c.Controller, 404, 404, "商机未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
+ return
+ }
if biz.Locked == 1 || biz.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "商机已转化,不能重复转化")
return
@@ -375,27 +397,33 @@ func (c *BackendCrmBusinessController) Convert() {
now := time.Now()
bizIDVal := biz.ID
proj := models.TenantCrmProject{
- TenantID: tenantID,
- ProjectName: projectName,
- ProjectNo: strings.TrimSpace(p.ProjectNo),
- BusinessID: &bizIDVal,
- CustomerID: biz.CustomerID,
- CustomerName: biz.CustomerName,
- OwnerUserID: biz.OwnerUserID,
- OwnerUserName: biz.OwnerUserName,
- Industry: biz.Industry,
- Amount: amount,
- Status: 1,
- StartDate: parsePipelineDate(p.StartDate),
- EndDate: parsePipelineDate(p.EndDate),
- ContactPerson: biz.ContactPerson,
+ TenantID: tenantID,
+ ProjectName: projectName,
+ ProjectNo: strings.TrimSpace(p.ProjectNo),
+ // 商机转化默认按「单一项目」创建:金额与对接人先落到上游侧,
+ // 后续可在项目中选择「上下游项目」并补充下游供应商 / 金额 / 对接人
+ ProjectType: 1,
+ BusinessID: &bizIDVal,
+ CustomerID: biz.CustomerID,
+ CustomerName: biz.CustomerName,
+ OwnerUserID: biz.OwnerUserID,
+ OwnerUserName: biz.OwnerUserName,
+ Industry: biz.Industry,
+ UpstreamAmount: amount,
+ Amount: amount, // 兼容旧字段
+ Status: 1,
+ StartDate: parsePipelineDate(p.StartDate),
+ EndDate: parsePipelineDate(p.EndDate),
+ UpstreamContact: biz.ContactPerson,
+ UpstreamPhone: biz.ContactPhone,
+ ContactPerson: biz.ContactPerson,
ContactPosition: biz.ContactPosition,
- ContactPhone: biz.ContactPhone,
- Address: biz.Address,
- Remark: p.Remark,
- CreateUserID: pipelineUID(claims),
- CreateTime: now,
- UpdateTime: now,
+ ContactPhone: biz.ContactPhone,
+ Address: biz.Address,
+ Remark: p.Remark,
+ CreateUserID: pipelineUID(claims),
+ CreateTime: now,
+ UpdateTime: now,
}
projID, err := models.Orm.Insert(&proj)
if err != nil {
diff --git a/go/controllers/backend_crm_clue.go b/go/controllers/backend_crm_clue.go
index 632e845..23a04c5 100644
--- a/go/controllers/backend_crm_clue.go
+++ b/go/controllers/backend_crm_clue.go
@@ -25,7 +25,8 @@ type cluePayload struct {
Source string `json:"source"`
OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"`
- Industry string `json:"industry"`
+ Industry string `json:"industry"`
+ Amount float64 `json:"amount"` // 预计金额
NextContactTime string `json:"next_contact_time"`
ClueLevel string `json:"clue_level"`
ContactPerson string `json:"contact_person"`
@@ -61,6 +62,8 @@ func (c *BackendCrmClueController) List() {
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
+ cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
kw := orm.NewCondition().
Or("clue_name__contains", keyword).
@@ -114,6 +117,11 @@ func (c *BackendCrmClueController) Detail() {
pipelineErr(&c.Controller, 404, 404, "线索未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
+ return
+ }
pipelineOk(&c.Controller, clue)
}
@@ -161,6 +169,7 @@ func (c *BackendCrmClueController) Create() {
OwnerUserID: ownerID,
OwnerUserName: ownerName,
Industry: strings.TrimSpace(p.Industry),
+ Amount: p.Amount,
NextContactTime: parsePipelineDateTime(p.NextContactTime),
ClueLevel: level,
ContactPerson: strings.TrimSpace(p.ContactPerson),
@@ -176,6 +185,11 @@ func (c *BackendCrmClueController) Create() {
CreateTime: now,
UpdateTime: now,
}
+ // 唯一性校验:同租户内不允许重复创建同名线索(重复数据请联系管理员转移跟进)
+ if crmNameDuplicated(new(models.TenantCrmClue), clue.TenantID, "clue_name", clue.ClueName) {
+ pipelineErr(&c.Controller, 409, 409, "该线索名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
+ return
+ }
id, err := models.Orm.Insert(&clue)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
@@ -212,6 +226,11 @@ func (c *BackendCrmClueController) Update() {
pipelineErr(&c.Controller, 404, 404, "线索未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
+ return
+ }
if clue.Locked == 1 {
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能编辑")
return
@@ -231,6 +250,7 @@ func (c *BackendCrmClueController) Update() {
clue.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
}
clue.Industry = strings.TrimSpace(p.Industry)
+ clue.Amount = p.Amount
clue.NextContactTime = parsePipelineDateTime(p.NextContactTime)
if strings.TrimSpace(p.ClueLevel) != "" {
clue.ClueLevel = strings.TrimSpace(p.ClueLevel)
@@ -272,6 +292,11 @@ func (c *BackendCrmClueController) Delete() {
pipelineErr(&c.Controller, 404, 404, "线索未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
+ return
+ }
if clue.Locked == 1 || clue.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能删除")
return
@@ -340,6 +365,11 @@ func (c *BackendCrmClueController) Convert() {
pipelineErr(&c.Controller, 404, 404, "线索未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
+ return
+ }
if clue.Locked == 1 || clue.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "线索已转化,不能重复转化")
return
diff --git a/go/controllers/backend_crm_contract.go b/go/controllers/backend_crm_contract.go
index b362982..b07e211 100644
--- a/go/controllers/backend_crm_contract.go
+++ b/go/controllers/backend_crm_contract.go
@@ -47,7 +47,6 @@ type contractPayload struct {
SignDate string `json:"sign_date"`
EffectiveDate string `json:"effective_date"`
ExpireDate string `json:"expire_date"`
- TechDate string `json:"tech_date"`
Parties json.RawMessage `json:"parties"`
Products json.RawMessage `json:"products"`
Step int8 `json:"step"`
@@ -87,6 +86,8 @@ func (c *BackendCrmContractController) List() {
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
+ cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
// 签约主体名称存储在 parties JSON 中,用 LIKE 一并匹配
kw := orm.NewCondition().
@@ -118,6 +119,20 @@ func (c *BackendCrmContractController) List() {
cond = cond.And("project_id", pid)
}
}
+ // 周期筛选:与顶部统计同口径(合同有效期 [生效日期, 结束日期] 与所选周期重叠),
+ // period=month/quarter/year 取当期区间,period=custom 用 start_date / end_date
+ period := strings.TrimSpace(c.GetString("period"))
+ rangeStart := strings.TrimSpace(c.GetString("start_date"))
+ rangeEnd := strings.TrimSpace(c.GetString("end_date"))
+ if period != "" && period != "custom" {
+ rangeStart, rangeEnd = periodRange(period)
+ }
+ if rangeStart != "" {
+ cond = cond.AndCond(orm.NewCondition().Or("expire_date__isnull", true).Or("expire_date__gte", rangeStart))
+ }
+ if rangeEnd != "" {
+ cond = cond.AndCond(orm.NewCondition().Or("effective_date__isnull", true).Or("effective_date__lte", rangeEnd))
+ }
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
total, _ := qs.Count()
@@ -127,7 +142,6 @@ func (c *BackendCrmContractController) List() {
}
items := make([]contractResp, 0, len(list))
for i := range list {
- c.syncContractExceptionStatus(&list[i])
items = append(items, buildContractResp(&list[i]))
}
pipelineOk(&c.Controller, map[string]interface{}{
@@ -185,7 +199,8 @@ func (c *BackendCrmContractController) Stats() {
})
}
-// periodRange 根据 period 返回 [start, end] 签约日期区间(YYYY-MM-DD):month/quarter/year 取当期。
+// periodRange 根据 period 返回当期区间 [start, end](YYYY-MM-DD):month/quarter/year。
+// 统计与列表均以该区间与合同有效期比较(有效期与周期重叠即命中)。
func periodRange(period string) (string, string) {
now := time.Now()
loc := now.Location()
@@ -226,7 +241,11 @@ func (c *BackendCrmContractController) Detail() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
- c.syncContractExceptionStatus(&row)
+ // 数据范围校验:非管理员仅能查看本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权查看该合同数据")
+ return
+ }
pipelineOk(&c.Controller, buildContractResp(&row))
}
@@ -262,7 +281,6 @@ func (c *BackendCrmContractController) Create() {
SignDate: parsePipelineDate(p.SignDate),
EffectiveDate: parsePipelineDate(p.EffectiveDate),
ExpireDate: parsePipelineDate(p.ExpireDate),
- TechDate: parsePipelineDate(p.TechDate),
Status: pickInt8(p.Status, 1, 5),
Step: pickInt8(p.Step, 1, 2),
Remark: p.Remark,
@@ -310,6 +328,12 @@ func (c *BackendCrmContractController) Create() {
}
applyContractAmounts(&row)
+ // 唯一性校验:同租户内「合同名称 + 签约方」完全一致才视为重复。
+ // 同名但签约方不同(例如我方分别作为甲方 / 乙方的两份合同)不算重复。
+ if crmContractDuplicated(tenantID, row.ContractName, row.Parties) {
+ pipelineErr(&c.Controller, 409, 409, "已存在同名且签约方相同的合同(可能由其他同事创建),如需继续跟进请联系管理员转移")
+ return
+ }
if _, err := models.Orm.Insert(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
@@ -345,6 +369,11 @@ func (c *BackendCrmContractController) Update() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
+ return
+ }
if strings.TrimSpace(p.ContractName) == "" {
pipelineErr(&c.Controller, 400, 400, "合同名称不能为空")
return
@@ -385,7 +414,6 @@ func (c *BackendCrmContractController) Update() {
row.SignDate = parsePipelineDate(p.SignDate)
row.EffectiveDate = parsePipelineDate(p.EffectiveDate)
row.ExpireDate = parsePipelineDate(p.ExpireDate)
- row.TechDate = parsePipelineDate(p.TechDate)
if p.Parties != nil {
parties, err := normalizeContractJSON(p.Parties)
if err != nil {
@@ -447,6 +475,11 @@ func (c *BackendCrmContractController) Delete() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
+ return
+ }
if !canDeleteCrmRecord(claims, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该合同")
return
@@ -497,6 +530,11 @@ func (c *BackendCrmContractController) ChangeStatus() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
+ return
+ }
if row.Status == p.Status {
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": row.Status})
return
@@ -684,25 +722,74 @@ func contractReceivedAmount(tenantID string, contractID uint64) float64 {
return toFloat64(rows[0]["received"])
}
-// syncContractExceptionStatus 履约中合同状态对账:技术日期已过但回款未回完(已收 < 合同总额,
-// 允许 0.005 浮点容差)时,自动流转为「执行异常」(5)。无技术日期或不满足翻转条件则不动。
-// 在列表 / 详情读取时触发,也可在回款登记后调用,使状态及时反映。
-func (c *BackendCrmContractController) syncContractExceptionStatus(row *models.TenantCrmContract) {
- if row == nil || row.Status != 4 || row.TechDate == nil || row.ID == 0 {
- return
+// crmContractDuplicated 判断同租户内是否已存在「合同名称 + 签约方」均一致的合同。
+//
+// 判重口径:合同名称一致,且参与方(甲/乙/丙/丁)归一化签名一致。
+// 因此同一名称下「我方作为甲方」与「我方作为乙方」的两份合同不算重复。
+func crmContractDuplicated(tenantID, name, partiesJSON string) bool {
+ name = strings.TrimSpace(name)
+ if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
+ return false
}
- now := time.Now()
- if !row.TechDate.Before(now) {
- return
+ sig := contractPartiesSignature(partiesJSON)
+ var rows []models.TenantCrmContract
+ _, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
+ Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).
+ Filter("contract_name", name).
+ All(&rows, "id", "parties")
+ if err != nil {
+ return false
}
- received := contractReceivedAmount(row.TenantID, row.ID)
- if row.TotalAmount > 0 && received+0.005 < row.TotalAmount {
- row.Status = 5
- row.UpdateTime = now
- _, _ = models.Orm.QueryTable(new(models.TenantCrmContract)).
- Filter("id", row.ID).Filter("tenant_id", row.TenantID).
- Update(orm.Params{"status": int8(5), "update_time": now})
+ for i := range rows {
+ if contractPartiesSignature(rows[i].Parties) == sig {
+ return true
+ }
}
+ return false
+}
+
+// contractPartiesSignature 参与方归一化签名:按甲/乙/丙/丁固定顺序拼接「角色:主体类型|主体」。
+// 主体优先取关联主体 ID(ref_id),无 ID(手工录入名称)时退化为名称(ref_name);
+// 参与方为空或解析失败时返回空串,此时判重退化为「仅合同名称一致」。
+func contractPartiesSignature(partiesJSON string) string {
+ if strings.TrimSpace(partiesJSON) == "" {
+ return ""
+ }
+ var parties []map[string]interface{}
+ if err := json.Unmarshal([]byte(partiesJSON), &parties); err != nil {
+ return ""
+ }
+ str := func(v interface{}) string {
+ if v == nil {
+ return ""
+ }
+ return strings.TrimSpace(fmt.Sprintf("%v", v))
+ }
+ byRole := make(map[string]string, len(parties))
+ for _, p := range parties {
+ role := str(p["role"])
+ if role == "" {
+ continue
+ }
+ subject := str(p["ref_id"])
+ // ref_id 为 0 / 空表示未关联主体库(本公司或手工录入),此时用名称兜底,
+ // 避免不同主体都归到 "0" 造成误判为重复
+ if subject == "" || subject == "0" {
+ subject = str(p["ref_name"])
+ }
+ byRole[role] = str(p["ref_type"]) + "|" + subject
+ }
+ var sb strings.Builder
+ for _, role := range []string{"party_a", "party_b", "party_c", "party_d"} {
+ if v, ok := byRole[role]; ok {
+ sb.WriteString(role)
+ sb.WriteString(":")
+ sb.WriteString(v)
+ sb.WriteString(";")
+ }
+ }
+ return sb.String()
}
// genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。
diff --git a/go/controllers/backend_crm_dashboard.go b/go/controllers/backend_crm_dashboard.go
index 9bf0a6b..52076ae 100644
--- a/go/controllers/backend_crm_dashboard.go
+++ b/go/controllers/backend_crm_dashboard.go
@@ -195,6 +195,7 @@ func crmSumEntities() map[string]dashEntity {
"projectAmount": {"yz_backend_crm_project", "create_time", ""},
"contractAmount": {"yz_backend_crm_contract", "create_time", ""},
"chanceAmount": {"yz_backend_crm_business", "create_time", ""},
+ "clueAmount": {"yz_backend_crm_clue", "create_time", ""},
"paymentAmount": {"yz_backend_crm_payback", "create_time", ""},
}
}
@@ -207,6 +208,8 @@ func crmAmountCol(key string) string {
return "total_amount"
case "chanceAmount":
return "amount"
+ case "clueAmount":
+ return "amount"
case "paymentAmount":
return "received_amount"
}
@@ -240,7 +243,7 @@ func (c *BackendCrmDashboardController) Summary() {
sums := crmSumEntities()
statKeys := []string{
"newCustomer", "newContact", "newProject", "newContract", "newClue",
- "newChance", "projectAmount", "contractAmount", "chanceAmount", "paymentAmount", "newVisit",
+ "newChance", "clueAmount", "chanceAmount", "projectAmount", "paymentAmount", "newVisit",
}
stats := map[string]map[string]float64{}
diff --git a/go/controllers/backend_crm_payback.go b/go/controllers/backend_crm_payback.go
index 3f03b3a..e65cb99 100644
--- a/go/controllers/backend_crm_payback.go
+++ b/go/controllers/backend_crm_payback.go
@@ -60,10 +60,12 @@ type paybackPayload struct {
ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填)
}
-// paybackResp 列表 / 详情响应:items 解析为 JSON 数组透出。
+// paybackResp 列表 / 详情响应:items 解析为 JSON 数组透出;
+// children 为回款明细(树形子集:该计划下一笔笔实际回款记录)。
type paybackResp struct {
models.TenantCrmPayback
- Items json.RawMessage `json:"items"`
+ Items json.RawMessage `json:"items"`
+ Children []models.TenantCrmPaybackRecord `json:"children"`
}
// paybackProgress 回款进度行(跨计划摊平的计划明细)。
@@ -109,6 +111,8 @@ func (c *BackendCrmPaybackController) List() {
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
+ cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
cond = cond.AndCond(orm.NewCondition().
Or("contract_name__contains", keyword).
@@ -138,9 +142,32 @@ func (c *BackendCrmPaybackController) List() {
// 排序:进行中(1) 在前、已完成(2) 在后,同状态内按最新创建优先
_, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
+ // 组装树形数据:每个回款计划挂上它的回款记录作为明细子集
+ planIDs := make([]uint64, 0, len(list))
+ for i := range list {
+ planIDs = append(planIDs, list[i].ID)
+ }
+ recordsByPlan := make(map[uint64][]models.TenantCrmPaybackRecord, len(list))
+ if len(planIDs) > 0 {
+ var recs []models.TenantCrmPaybackRecord
+ _, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
+ Filter("tenant_id", tenantID).
+ Filter("payback_id__in", planIDs).
+ Filter("delete_time__isnull", true).
+ OrderBy("-received_date", "-id").All(&recs)
+ for _, r := range recs {
+ recordsByPlan[r.PaybackID] = append(recordsByPlan[r.PaybackID], r)
+ }
+ }
items := make([]paybackResp, 0, len(list))
for i := range list {
- items = append(items, buildPaybackResp(&list[i]))
+ resp := buildPaybackResp(&list[i])
+ if recs := recordsByPlan[list[i].ID]; len(recs) > 0 {
+ resp.Children = recs
+ } else {
+ resp.Children = []models.TenantCrmPaybackRecord{}
+ }
+ items = append(items, resp)
}
// 汇总(当前筛选条件下全部计划的计划总额 / 已回款 / 待回款),用于列表页统计卡片
@@ -271,6 +298,11 @@ func (c *BackendCrmPaybackController) Update() {
pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
+ return
+ }
if p.ContractID > 0 {
row.ContractID = &p.ContractID
@@ -302,20 +334,23 @@ func (c *BackendCrmPaybackController) Update() {
row.Status = pickInt8(p.Status, 1, 2)
}
if p.Items != nil {
- itemsJSON, _, err := normalizePaybackItems(p.Items)
+ itemsJSON, total, err := normalizePaybackItems(p.Items)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "计划明细格式错误")
return
}
row.Items = itemsJSON
+ row.TotalAmount = total // 计划总额随明细重算
}
- applyPaybackReceived(&row)
row.UpdateTime = time.Now()
if _, err := models.Orm.Update(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
+ // 已回款金额一律以「回款记录」为准重新汇总:
+ // 明细里不承载实收数据,若此处按明细重算,编辑计划(如调整回款日期)会把已回款冲成 0。
+ refreshPlanReceived(tenantID, row.ID)
crmWriteLog(tenantID, 4, row.ID, "update", "更新回款计划:"+row.ContractName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
@@ -340,10 +375,20 @@ func (c *BackendCrmPaybackController) Delete() {
pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
+ return
+ }
if !canDeleteCrmRecord(claims, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该回款计划")
return
}
+ // 已完成(回款已收齐)的回款计划不允许删除
+ if row.Status == 2 {
+ pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不允许删除")
+ return
+ }
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmPayback)).
Filter("id", id).Filter("tenant_id", tenantID).
@@ -369,6 +414,8 @@ func (c *BackendCrmPaybackController) ProgressList() {
contractID := strings.TrimSpace(c.GetString("contract_id"))
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
+ cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
cond = cond.AndCond(orm.NewCondition().
Or("contract_name__contains", keyword).
@@ -456,6 +503,11 @@ func (c *BackendCrmPaybackController) Receive() {
pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
+ return
+ }
items := parsePaybackItems(row.Items)
matched := false
for i := range items {
@@ -664,6 +716,8 @@ func (c *BackendCrmPaybackController) RecordList() {
paybackID, _ := c.GetUint64("payback_id")
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
+ cond = crmApplyOwnerScope(cond, claims)
if contractID > 0 {
cond = cond.And("contract_id", contractID)
}
@@ -678,6 +732,7 @@ func (c *BackendCrmPaybackController) RecordList() {
// (已回款由回款记录汇总回写,兼容历史计划)
var totalPlanned, totalReceived float64
planCond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ planCond = crmApplyOwnerScope(planCond, claims)
if contractID > 0 {
planCond = planCond.And("contract_id", contractID)
}
@@ -792,6 +847,14 @@ func (c *BackendCrmPaybackController) DeleteRecord() {
pipelineErr(&c.Controller, 404, 404, "回款记录不存在")
return
}
+ // 所属回款计划已完成(回款已收齐)时不允许删除回款记录,避免破坏已完成状态
+ var plan models.TenantCrmPayback
+ if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
+ Filter("id", row.PaybackID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&plan); err == nil && plan.Status == 2 {
+ pipelineErr(&c.Controller, 400, 400, "所属回款计划已完成(回款已收齐),不允许删除回款记录")
+ return
+ }
now := time.Now()
if _, err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
Filter("id", id).Filter("tenant_id", tenantID).
diff --git a/go/controllers/backend_crm_permission.go b/go/controllers/backend_crm_permission.go
index 09f82eb..033ebef 100644
--- a/go/controllers/backend_crm_permission.go
+++ b/go/controllers/backend_crm_permission.go
@@ -6,6 +6,8 @@ import (
"server/models"
"server/pkg/jwtutil"
+
+ "github.com/beego/beego/v2/client/orm"
)
// CRM 客户 / 供应商的删除权限规则:
@@ -70,3 +72,67 @@ func isTenantAdmin(userID int, tenantID int) bool {
}
return rights == "" || rights == "[]" || rights == "null"
}
+
+// ========================== CRM 数据范围(用户级隔离) ==========================
+//
+// 数据可见范围约定(仅作用于用户级业务数据):
+// - 平台管理员 / 租户管理员(含未分配角色、角色未配置权限等全权限情形)→ 可见租户内全部数据;
+// - 其他用户 → 仅可见「负责人是自己」或「由自己创建」的数据。
+//
+// 租户共享数据不在此列,始终对全租户可见:供应商、客户公海、产品列表、产品分类。
+// 后续如需按角色配置数据范围,只需扩展 crmScopeSelfOnly 的判定。
+
+// crmScopeSelfOnly 判断当前用户是否只能看「本人」数据(true=仅本人)。
+func crmScopeSelfOnly(claims *jwtutil.Claims) bool {
+ if claims == nil {
+ return true
+ }
+ if claims.UserType == "platform" {
+ return false
+ }
+ return !isTenantAdmin(claims.UserID, claims.TenantId)
+}
+
+// crmApplyOwnerScope 为列表查询条件追加数据范围限制(需要「仅本人」时)。
+// 需要用户级隔离的模块请在构造完 tenant 条件后调用。
+func crmApplyOwnerScope(cond *orm.Condition, claims *jwtutil.Claims) *orm.Condition {
+ if !crmScopeSelfOnly(claims) {
+ return cond
+ }
+ uid := pipelineUID(claims)
+ return cond.AndCond(orm.NewCondition().
+ Or("owner_user_id", uid).
+ Or("create_user_id", uid))
+}
+
+// canAccessCrmRecord 判断当前用户是否有权访问(查看 / 编辑 / 删除)指定记录。
+// ownerUserID / createUserID 为记录的负责人与创建人(历史数据可能为空)。
+func canAccessCrmRecord(claims *jwtutil.Claims, ownerUserID, createUserID string) bool {
+ if claims == nil {
+ return false
+ }
+ // 平台 / 租户管理员可访问全部
+ if !crmScopeSelfOnly(claims) {
+ return true
+ }
+ uid := pipelineUID(claims)
+ if ownerUserID != "" && ownerUserID == uid {
+ return true
+ }
+ return createUserID != "" && createUserID == uid
+}
+
+// crmNameDuplicated 判断同租户内是否已存在同名记录(不含软删除),用于创建时的唯一性校验。
+// model 传 new(models.XXX);nameField 为数据库列名(如 clue_name)。
+func crmNameDuplicated(model interface{}, tenantID, nameField, name string) bool {
+ name = strings.TrimSpace(name)
+ if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
+ return false
+ }
+ cnt, err := models.Orm.QueryTable(model).
+ Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).
+ Filter(nameField, name).
+ Count()
+ return err == nil && cnt > 0
+}
diff --git a/go/controllers/backend_crm_product.go b/go/controllers/backend_crm_product.go
index f9702ae..0efabc2 100644
--- a/go/controllers/backend_crm_product.go
+++ b/go/controllers/backend_crm_product.go
@@ -146,6 +146,13 @@ func (c *BackendCrmProductController) Create() {
if lineType != "dev" {
lineType = "product"
}
+ // 非开发行不保留开发字段,避免残留(如 dev_tree="[]")导致清单调用时被误判为软件开发行
+ devUnitPrice := p.DevUnitPrice
+ devTree := string(p.DevTree)
+ if lineType != "dev" {
+ devUnitPrice = 0
+ devTree = ""
+ }
now := time.Now()
row := models.TenantCrmProduct{
TenantID: pipelineTenantID(claims),
@@ -158,8 +165,8 @@ func (c *BackendCrmProductController) Create() {
CostPrice: p.CostPrice,
TaxRate: p.TaxRate,
LineType: lineType,
- DevUnitPrice: p.DevUnitPrice,
- DevTree: string(p.DevTree),
+ DevUnitPrice: devUnitPrice,
+ DevTree: devTree,
Status: status,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
@@ -212,24 +219,31 @@ func (c *BackendCrmProductController) Update() {
if lineType != "dev" {
lineType = "product"
}
+ // 非开发行清空开发字段,避免残留导致清单调用时被误判为软件开发行
+ devUnitPrice := p.DevUnitPrice
+ devTree := string(p.DevTree)
+ if lineType != "dev" {
+ devUnitPrice = 0
+ devTree = ""
+ }
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,
+ "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": devUnitPrice,
+ "dev_tree": devTree,
+ "status": status,
+ "remark": p.Remark,
+ "update_time": now,
})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
diff --git a/go/controllers/backend_crm_project.go b/go/controllers/backend_crm_project.go
index c09c8ff..38c8c54 100644
--- a/go/controllers/backend_crm_project.go
+++ b/go/controllers/backend_crm_project.go
@@ -22,23 +22,141 @@ type BackendCrmProjectController struct {
}
type projectPayload struct {
- ProjectName string `json:"project_name"`
- ProjectNo string `json:"project_no"`
- CustomerID uint64 `json:"customer_id"`
- CustomerName string `json:"customer_name"`
- OwnerUserID string `json:"owner_user_id"`
- OwnerUserName string `json:"owner_user_name"`
- Industry string `json:"industry"`
- Amount float64 `json:"amount"`
- Status int8 `json:"status"`
- StartDate string `json:"start_date"`
- EndDate string `json:"end_date"`
- ContactPerson string `json:"contact_person"`
- ContactPosition string `json:"contact_position"`
- ContactPhone string `json:"contact_phone"`
- Address string `json:"address"`
- Remark string `json:"remark"`
- Products string `json:"products"` // 项目产品清单JSON(生成时写入产品管理)
+ ProjectName string `json:"project_name"`
+ ProjectNo string `json:"project_no"`
+ // 项目类型:1=单一项目(仅上游客户) 2=上下游项目(上游客户 + 下游供应商)
+ ProjectType int8 `json:"project_type"`
+ CustomerID uint64 `json:"customer_id"`
+ CustomerName string `json:"customer_name"`
+ SupplierID uint64 `json:"supplier_id"`
+ SupplierRefType int8 `json:"supplier_ref_type"` // 下游主体来源:1客户 2供应商
+ SupplierName string `json:"supplier_name"`
+ SupplierIndustry string `json:"supplier_industry"`
+ OwnerUserID string `json:"owner_user_id"`
+ OwnerUserName string `json:"owner_user_name"`
+ Industry string `json:"industry"`
+ // 金额:上游=对客户收入,下游=对供应商成本
+ UpstreamAmount float64 `json:"upstream_amount"`
+ DownstreamAmount float64 `json:"downstream_amount"`
+ // Amount 为旧「项目金额」,未传上游金额时回退使用(兼容未升级的调用方)
+ Amount float64 `json:"amount"`
+ Status int8 `json:"status"`
+ StartDate string `json:"start_date"`
+ EndDate string `json:"end_date"`
+ // 上下游对接人:仅姓名 + 电话;Contact* 为旧字段,未传新字段时回退使用
+ UpstreamContact string `json:"upstream_contact"`
+ UpstreamPhone string `json:"upstream_phone"`
+ DownstreamContact string `json:"downstream_contact"`
+ DownstreamPhone string `json:"downstream_phone"`
+ ContactPerson string `json:"contact_person"`
+ ContactPosition string `json:"contact_position"`
+ ContactPhone string `json:"contact_phone"`
+ Address string `json:"address"`
+ Remark string `json:"remark"`
+ Products string `json:"products"` // 项目产品清单JSON(生成时写入产品管理)
+}
+
+// normalizeProjectFields 归一项目类型、上下游金额与上下游对接人:
+// - 项目类型仅识别 2=上下游,其余一律按 1=单一项目处理;
+// - 上游金额 / 上游对接人缺省时回退旧的 amount / contact_* 字段(兼容未升级的调用方);
+// - 单一项目不存在下游,下游金额与下游对接人一并清空。
+func normalizeProjectFields(p *projectPayload) (pt int8, upstream, downstream float64, upContact, upPhone, downContact, downPhone string) {
+ pt = p.ProjectType
+ if pt != 2 {
+ pt = 1
+ }
+ upstream = p.UpstreamAmount
+ if upstream == 0 && p.Amount != 0 {
+ upstream = p.Amount
+ }
+ upContact = strings.TrimSpace(firstNonEmpty(p.UpstreamContact, p.ContactPerson))
+ upPhone = strings.TrimSpace(firstNonEmpty(p.UpstreamPhone, p.ContactPhone))
+ if pt == 2 {
+ downstream = p.DownstreamAmount
+ downContact = strings.TrimSpace(p.DownstreamContact)
+ downPhone = strings.TrimSpace(p.DownstreamPhone)
+ }
+ return
+}
+
+// resolveProjectSupplier 解析项目下游主体:
+// refType=2 关联供应商库,refType=1 关联客户库(同一家单位可能以客户身份建档);
+// 命中时以库中的 ID 与标准名称为准,未命中(或未选择库记录)时保留手工填写的名称、不保留关联。
+func resolveProjectSupplier(tenantID string, refType int8, refID uint64, name string) (*uint64, int8, string) {
+ name = strings.TrimSpace(name)
+ if refID == 0 {
+ return nil, 0, name
+ }
+ switch refType {
+ case 2:
+ var sup models.TenantCrmSupplier
+ if err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
+ Filter("id", refID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&sup); err != nil {
+ return nil, 0, name
+ }
+ id := sup.ID
+ if strings.TrimSpace(sup.SupplierName) != "" {
+ name = sup.SupplierName
+ }
+ return &id, 2, name
+ case 1:
+ var cus models.TenantCrmCustomer
+ if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
+ Filter("id", refID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&cus); err != nil {
+ return nil, 0, name
+ }
+ id := cus.ID
+ if strings.TrimSpace(cus.CustomerName) != "" {
+ name = cus.CustomerName
+ }
+ return &id, 1, name
+ }
+ return nil, 0, name
+}
+
+// attachProjectContractPeriod 汇总项目下所有合同的实际起止时间:
+// 开始取各合同最早的生效日期(未填生效日期时回退签订日期),结束取最晚的结束日期。
+// 项目不再手工维护起止时间,列表 / 详情统一按此展示;改合同后自动跟随,无需同步。
+func attachProjectContractPeriod(tenantID string, list []models.TenantCrmProject) {
+ if len(list) == 0 {
+ return
+ }
+ idx := make(map[uint64]int, len(list))
+ ids := make([]uint64, 0, len(list))
+ for i := range list {
+ idx[list[i].ID] = i
+ ids = append(ids, list[i].ID)
+ }
+ var rows []models.TenantCrmContract
+ _, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
+ Filter("tenant_id", tenantID).Filter("delete_time__isnull", true).
+ Filter("project_id__in", ids).
+ All(&rows, "project_id", "sign_date", "effective_date", "expire_date")
+ if err != nil {
+ return
+ }
+ for i := range rows {
+ ct := rows[i]
+ if ct.ProjectID == nil {
+ continue
+ }
+ pos, ok := idx[*ct.ProjectID]
+ if !ok {
+ continue
+ }
+ start := ct.EffectiveDate
+ if start == nil {
+ start = ct.SignDate
+ }
+ if start != nil && (list[pos].ContractStartDate == nil || start.Before(*list[pos].ContractStartDate)) {
+ list[pos].ContractStartDate = start
+ }
+ if ct.ExpireDate != nil && (list[pos].ContractEndDate == nil || ct.ExpireDate.After(*list[pos].ContractEndDate)) {
+ list[pos].ContractEndDate = ct.ExpireDate
+ }
+ }
}
// List GET /backend/crm/project/list
@@ -62,11 +180,14 @@ func (c *BackendCrmProjectController) List() {
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
+ cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
kw := orm.NewCondition().
Or("project_name__contains", keyword).
Or("project_no__contains", keyword).
- Or("customer_name__contains", keyword)
+ Or("customer_name__contains", keyword).
+ Or("supplier_name__contains", keyword)
cond = cond.AndCond(kw)
}
if status != "" {
@@ -83,6 +204,8 @@ func (c *BackendCrmProjectController) List() {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
attachBusinessName(tenantID, list)
+ // 项目起止时间由关联合同汇总(不再依赖手工维护的开始 / 结束日期)
+ attachProjectContractPeriod(tenantID, list)
pipelineOk(&c.Controller, map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize,
})
@@ -110,7 +233,10 @@ func (c *BackendCrmProjectController) Detail() {
}
tmp := []models.TenantCrmProject{proj}
attachBusinessName(pipelineTenantID(claims), tmp)
+ attachProjectContractPeriod(pipelineTenantID(claims), tmp)
proj.BusinessName = tmp[0].BusinessName
+ proj.ContractStartDate = tmp[0].ContractStartDate
+ proj.ContractEndDate = tmp[0].ContractEndDate
// 存量项目兜底:确保文档库「共享文档 / 项目文档」下已建立同名文件夹(失败静默,不影响详情)
if proj.DocCategoryID == 0 {
if cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, proj.ID, proj.ProjectName, 0); err == nil && cid > 0 {
@@ -155,29 +281,50 @@ func (c *BackendCrmProjectController) Create() {
if status == 0 {
status = 1
}
+ // 项目类型 / 上下游金额 / 上下游对接人归一(单一项目自动清空下游信息)
+ pt, upstream, downstream, upContact, upPhone, downContact, downPhone := normalizeProjectFields(&p)
+ supplierID, supplierRefType, supplierName := resolveProjectSupplier(tenantID, p.SupplierRefType, p.SupplierID, p.SupplierName)
+ if pt == 1 {
+ supplierID, supplierRefType, supplierName = nil, 0, ""
+ }
now := time.Now()
proj := models.TenantCrmProject{
- TenantID: tenantID,
- ProjectName: strings.TrimSpace(p.ProjectName),
- ProjectNo: strings.TrimSpace(p.ProjectNo),
- CustomerID: custIDVal,
- CustomerName: customerName,
- OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
- OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
- Industry: strings.TrimSpace(p.Industry),
- Amount: p.Amount,
- Status: status,
- StartDate: parsePipelineDate(p.StartDate),
- EndDate: parsePipelineDate(p.EndDate),
- ContactPerson: strings.TrimSpace(p.ContactPerson),
- ContactPosition: strings.TrimSpace(p.ContactPosition),
- ContactPhone: strings.TrimSpace(p.ContactPhone),
- Address: strings.TrimSpace(p.Address),
- Remark: p.Remark,
- Products: p.Products,
- CreateUserID: pipelineUID(claims),
- CreateTime: now,
- UpdateTime: now,
+ TenantID: tenantID,
+ ProjectName: strings.TrimSpace(p.ProjectName),
+ ProjectNo: strings.TrimSpace(p.ProjectNo),
+ ProjectType: pt,
+ CustomerID: custIDVal,
+ CustomerName: customerName,
+ SupplierID: supplierID,
+ SupplierRefType: supplierRefType,
+ SupplierName: supplierName,
+ SupplierIndustry: strings.TrimSpace(p.SupplierIndustry),
+ OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
+ OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
+ Industry: strings.TrimSpace(p.Industry),
+ UpstreamAmount: upstream,
+ DownstreamAmount: downstream,
+ Amount: upstream, // 兼容旧字段:始终等于上游金额
+ Status: status,
+ StartDate: parsePipelineDate(p.StartDate),
+ EndDate: parsePipelineDate(p.EndDate),
+ UpstreamContact: upContact,
+ UpstreamPhone: upPhone,
+ DownstreamContact: downContact,
+ DownstreamPhone: downPhone,
+ ContactPerson: upContact, // 旧字段同步为上游对接人,便于历史查询
+ ContactPhone: upPhone,
+ Address: strings.TrimSpace(p.Address),
+ Remark: p.Remark,
+ Products: p.Products,
+ CreateUserID: pipelineUID(claims),
+ CreateTime: now,
+ UpdateTime: now,
+ }
+ // 唯一性校验:同租户内不允许重复创建同名项目(重复数据请联系管理员转移跟进)
+ if crmNameDuplicated(new(models.TenantCrmProject), tenantID, "project_name", proj.ProjectName) {
+ pipelineErr(&c.Controller, 409, 409, "该项目名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
+ return
}
id, err := models.Orm.Insert(&proj)
if err != nil {
@@ -228,6 +375,11 @@ func (c *BackendCrmProjectController) Update() {
pipelineErr(&c.Controller, 404, 404, "项目未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据")
+ return
+ }
if strings.TrimSpace(p.ProjectName) == "" {
pipelineErr(&c.Controller, 400, 400, "项目名称不能为空")
return
@@ -254,15 +406,31 @@ func (c *BackendCrmProjectController) Update() {
proj.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
}
proj.Industry = strings.TrimSpace(p.Industry)
- proj.Amount = p.Amount
+ // 项目类型 / 上下游金额 / 上下游对接人(单一项目自动清空下游信息)
+ pt, upstream, downstream, upContact, upPhone, downContact, downPhone := normalizeProjectFields(&p)
+ supplierID, supplierRefType, supplierName := resolveProjectSupplier(tenantID, p.SupplierRefType, p.SupplierID, p.SupplierName)
+ if pt == 1 {
+ supplierID, supplierRefType, supplierName = nil, 0, ""
+ }
+ proj.ProjectType = pt
+ proj.SupplierID = supplierID
+ proj.SupplierRefType = supplierRefType
+ proj.SupplierName = supplierName
+ proj.SupplierIndustry = strings.TrimSpace(p.SupplierIndustry)
+ proj.UpstreamAmount = upstream
+ proj.DownstreamAmount = downstream
+ proj.Amount = upstream // 兼容旧字段:始终等于上游金额
+ proj.UpstreamContact = upContact
+ proj.UpstreamPhone = upPhone
+ proj.DownstreamContact = downContact
+ proj.DownstreamPhone = downPhone
+ proj.ContactPerson = upContact
+ proj.ContactPhone = upPhone
if p.Status != 0 {
proj.Status = p.Status
}
proj.StartDate = parsePipelineDate(p.StartDate)
proj.EndDate = parsePipelineDate(p.EndDate)
- proj.ContactPerson = strings.TrimSpace(p.ContactPerson)
- proj.ContactPosition = strings.TrimSpace(p.ContactPosition)
- proj.ContactPhone = strings.TrimSpace(p.ContactPhone)
proj.Address = strings.TrimSpace(p.Address)
proj.Remark = p.Remark
proj.Products = p.Products
@@ -307,6 +475,11 @@ func (c *BackendCrmProjectController) Delete() {
pipelineErr(&c.Controller, 404, 404, "项目未找到")
return
}
+ // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
+ if !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据")
+ return
+ }
if !canDeleteCrmRecord(claims, proj.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该项目")
return
diff --git a/go/models/init.go b/go/models/init.go
index 6da0fb3..86f29cf 100644
--- a/go/models/init.go
+++ b/go/models/init.go
@@ -148,8 +148,8 @@ func Init(_ string) {
EnsureCrmCreateUserColumn()
EnsureCrmProjectDocColumn()
EnsureCrmContractTable()
+ EnsureCrmClueAmountColumn()
EnsureCrmContractOurRoleColumn()
- EnsureCrmContractTechDateColumn()
EnsureCrmProjectProductsColumn()
EnsureCrmPaybackTable()
EnsureCrmPaybackRecordTable()
@@ -234,11 +234,11 @@ func EnsureCrmContractOurRoleColumn() {
_, _ = Orm.Raw(sql).Exec()
}
-// EnsureCrmContractTechDateColumn 补齐合同表的技术日期字段(开发/交付完成日期;
+// EnsureCrmClueAmountColumn 补齐线索表的预计金额字段(线索阶段预估金额;
// 存量表已建时新增,表不存在或列已存在时忽略错误)。
-func EnsureCrmContractTechDateColumn() {
- sql := "ALTER TABLE " + new(TenantCrmContract).TableName() +
- " ADD COLUMN tech_date date DEFAULT NULL COMMENT '技术日期(开发/交付完成日期;到期且回款未满则自动转执行异常)'"
+func EnsureCrmClueAmountColumn() {
+ sql := "ALTER TABLE " + new(TenantCrmClue).TableName() +
+ " ADD COLUMN amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '预计金额'"
_, _ = Orm.Raw(sql).Exec()
}
diff --git a/go/models/tenant_crm_clue.go b/go/models/tenant_crm_clue.go
index cc1911c..260a63b 100644
--- a/go/models/tenant_crm_clue.go
+++ b/go/models/tenant_crm_clue.go
@@ -13,6 +13,7 @@ type TenantCrmClue struct {
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"` // 负责人
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
Industry string `orm:"column(industry);size(50)" json:"industry"` // 客户行业
+ Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"` // 预计金额(线索阶段预估,用于仪表盘线索金额统计)
NextContactTime *time.Time `orm:"column(next_contact_time);type(datetime);null" json:"next_contact_time"` // 下次联系时间
ClueLevel string `orm:"column(clue_level);size(20);default(2)" json:"clue_level"` // 客户级别:1重点/2普通/3非优先
ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"` // 客户对接人
diff --git a/go/models/tenant_crm_contract.go b/go/models/tenant_crm_contract.go
index b66fdac..aefa242 100644
--- a/go/models/tenant_crm_contract.go
+++ b/go/models/tenant_crm_contract.go
@@ -25,7 +25,6 @@ type TenantCrmContract struct {
SignDate *time.Time `orm:"column(sign_date);type(date);null" json:"sign_date"`
EffectiveDate *time.Time `orm:"column(effective_date);type(date);null" json:"effective_date"`
ExpireDate *time.Time `orm:"column(expire_date);type(date);null" json:"expire_date"`
- TechDate *time.Time `orm:"column(tech_date);type(date);null" json:"tech_date"` // 技术日期(开发/交付完成日期;到期且回款未满则自动转执行异常)
Parties string `orm:"column(parties);type(text);null" json:"-"` // 各方签约主体 JSON 数组
Products string `orm:"column(products);type(text);null" json:"-"` // 产品清单 JSON 数组
HardwareAmount float64 `orm:"column(hardware_amount);digits(14);decimals(2);default(0)" json:"hardware_amount"`
diff --git a/go/models/tenant_crm_project.go b/go/models/tenant_crm_project.go
index 946d85a..4d97dd1 100644
--- a/go/models/tenant_crm_project.go
+++ b/go/models/tenant_crm_project.go
@@ -4,26 +4,47 @@ import "time"
// TenantCrmProject 项目表: yz_backend_crm_project
type TenantCrmProject struct {
- ID uint64 `orm:"column(id);pk;auto" json:"id"`
- TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
- ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 项目名称
- ProjectNo string `orm:"column(project_no);size(50)" json:"project_no"` // 项目编号
- BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 来源商机ID
- BusinessName string `orm:"-" json:"business_name"` // 来源商机名称(非表字段,查询时填充)
- CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"`
- CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
- OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
- OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
- Industry string `orm:"column(industry);size(50)" json:"industry"`
- Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"`
- Status int8 `orm:"column(status);default(1)" json:"status"` // 1未开始/2进行中/3已完成/4已暂停
- StartDate *time.Time `orm:"column(start_date);type(date);null" json:"start_date"`
- EndDate *time.Time `orm:"column(end_date);type(date);null" json:"end_date"`
- ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"`
- ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位
- ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
- Address string `orm:"column(address);size(255)" json:"address"`
- Remark string `orm:"column(remark);type(text);null" json:"remark"`
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
+ ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 项目名称
+ ProjectNo string `orm:"column(project_no);size(50)" json:"project_no"` // 项目编号
+ BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 来源商机ID
+ BusinessName string `orm:"-" json:"business_name"` // 来源商机名称(非表字段,查询时填充)
+ CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"`
+ CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
+ OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
+ OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
+ Industry string `orm:"column(industry);size(50)" json:"industry"`
+ // 项目类型:1=单一项目(只有上游客户) 2=上下游项目(上游客户 + 下游供应商)
+ ProjectType int8 `orm:"column(project_type);default(1)" json:"project_type"`
+ // 下游主体:可从供应商库或客户库选择(supplier_ref_type + supplier_id 标明来源),也允许手工填写名称
+ SupplierID *uint64 `orm:"column(supplier_id);null" json:"supplier_id"`
+ SupplierRefType int8 `orm:"column(supplier_ref_type);default(0)" json:"supplier_ref_type"` // 0未关联 1客户 2供应商
+ SupplierName string `orm:"column(supplier_name);size(100)" json:"supplier_name"`
+ SupplierIndustry string `orm:"column(supplier_industry);size(50)" json:"supplier_industry"`
+ // 金额拆分为上下游:上游=对客户的收入,下游=对供应商的成本(上下游项目毛利 = 上游 - 下游)
+ UpstreamAmount float64 `orm:"column(upstream_amount);digits(14);decimals(2);default(0)" json:"upstream_amount"`
+ DownstreamAmount float64 `orm:"column(downstream_amount);digits(14);decimals(2);default(0)" json:"downstream_amount"`
+ // Amount 为旧「项目金额」列,已由 UpstreamAmount 取代;写入时始终同步为上游金额,仅作历史兼容
+ Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"`
+ Status int8 `orm:"column(status);default(1)" json:"status"` // 1未开始/2进行中/3已完成/4已暂停
+ // StartDate / EndDate 为旧的手工维护列,已改由关联合同自动汇总,保留仅用于兼容历史数据
+ StartDate *time.Time `orm:"column(start_date);type(date);null" json:"start_date"`
+ EndDate *time.Time `orm:"column(end_date);type(date);null" json:"end_date"`
+ // 项目实际起止时间:按其下所有合同汇总(最早生效日期 / 最晚结束日期),只读不落库
+ ContractStartDate *time.Time `orm:"-" json:"contract_start_date"`
+ ContractEndDate *time.Time `orm:"-" json:"contract_end_date"`
+ // 上下游对接人:仅需姓名与电话
+ UpstreamContact string `orm:"column(upstream_contact);size(50)" json:"upstream_contact"`
+ UpstreamPhone string `orm:"column(upstream_phone);size(20)" json:"upstream_phone"`
+ DownstreamContact string `orm:"column(downstream_contact);size(50)" json:"downstream_contact"`
+ DownstreamPhone string `orm:"column(downstream_phone);size(20)" json:"downstream_phone"`
+ // 以下为旧版单一对接人信息,已被上下游对接人取代,仅保留列以兼容历史数据
+ ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"`
+ ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位(已废弃)
+ ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
+ Address string `orm:"column(address);size(255)" json:"address"`
+ Remark string `orm:"column(remark);type(text);null" json:"remark"`
// DocCategoryID 项目在 OA 文档库的文件夹分类ID:
// 指向 yz_backend_oa_doc_category 中「共享文档 / 项目文档 / {项目名称}」这条记录(0 表示尚未建立)。
DocCategoryID uint64 `orm:"column(doc_category_id);default(0)" json:"doc_category_id"`