From a8c09c1db515bcc69e96b4608789a9953d8a9ab5 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 22:31:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/utils/request.js | 6 + .../views/apps/crm/clue/components/edit.vue | 10 +- .../crm/contract/components/PartySelect.vue | 120 ++++- .../crm/contract/components/ProductList.vue | 17 +- .../apps/crm/contract/components/create.vue | 42 +- .../apps/crm/contract/components/detail.vue | 12 +- .../crm/contract/components/detail_basic.vue | 1 - .../contract/components/detail_payback.vue | 26 +- backend/src/views/apps/crm/contract/index.vue | 77 +++- .../src/views/apps/crm/dashboard/index.vue | 23 +- backend/src/views/apps/crm/dict.js | 3 +- .../apps/crm/payback/components/detail.vue | 45 +- backend/src/views/apps/crm/payback/index.vue | 194 +++++++-- .../apps/crm/product/components/edit.vue | 5 +- .../crm/project/components/detail_basic.vue | 129 ++++-- .../project/components/detail_contract.vue | 90 +++- .../apps/crm/project/components/edit.vue | 411 +++++++++++++++--- backend/src/views/apps/crm/project/index.vue | 36 +- go/controllers/backend_crm_business.go | 68 ++- go/controllers/backend_crm_clue.go | 32 +- go/controllers/backend_crm_contract.go | 131 +++++- go/controllers/backend_crm_dashboard.go | 5 +- go/controllers/backend_crm_payback.go | 73 +++- go/controllers/backend_crm_permission.go | 66 +++ go/controllers/backend_crm_product.go | 46 +- go/controllers/backend_crm_project.go | 259 +++++++++-- go/models/init.go | 10 +- go/models/tenant_crm_clue.go | 1 + go/models/tenant_crm_contract.go | 1 - go/models/tenant_crm_project.go | 61 ++- 30 files changed, 1654 insertions(+), 346 deletions(-) diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js index 256efe5..c9c117e 100644 --- a/backend/src/utils/request.js +++ b/backend/src/utils/request.js @@ -42,6 +42,9 @@ service.interceptors.response.use( }, error => { if (error.response) { + // 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作), + // 避免页面只显示 "Request failed with status code xxx" + const bizMsg = error.response.data?.msg || error.response.data?.message; switch (error.response.status) { case 401: console.error('未授权,请重新登录'); @@ -57,6 +60,9 @@ service.interceptors.response.use( default: console.error('请求失败,请稍后再试'); } + if (bizMsg) { + return Promise.reject(new Error(bizMsg)); + } } else if (error.request) { if (error.code === 'ECONNABORTED') { console.error('请求超时,请稍后重试'); diff --git a/backend/src/views/apps/crm/clue/components/edit.vue b/backend/src/views/apps/crm/clue/components/edit.vue index aa92199..df8b707 100644 --- a/backend/src/views/apps/crm/clue/components/edit.vue +++ b/backend/src/views/apps/crm/clue/components/edit.vue @@ -56,6 +56,13 @@ + + + + + + + @@ -142,6 +149,7 @@ const defaultForm = () => ({ owner_user_id: "", owner_user_name: "", industry: "", + amount: "", next_contact_time: "", clue_level: "2", contact_person: "", @@ -220,7 +228,7 @@ async function handleSubmit() { if (owner) form.owner_user_name = owner.name; submitting.value = true; try { - const payload = { ...form }; + const payload = { ...form, amount: Number(form.amount) || 0 }; if (internalId.value) { await updateClue(internalId.value, payload); ElMessage.success("更新成功"); diff --git a/backend/src/views/apps/crm/contract/components/PartySelect.vue b/backend/src/views/apps/crm/contract/components/PartySelect.vue index f3069dc..2f9d8bc 100644 --- a/backend/src/views/apps/crm/contract/components/PartySelect.vue +++ b/backend/src/views/apps/crm/contract/components/PartySelect.vue @@ -6,7 +6,7 @@ {{ party.ref_type === 2 ? "供应商" : "客户" }} - {{ tenantName }} + {{ party?.ref_name || tenantName }} @@ -62,13 +62,30 @@ - +
- + filterable + allow-create + default-first-option + clearable + :loading="signerLoading" + :placeholder="contactPlaceholder" + @change="handleSignerPick" + > + + {{ c.contact_name }} + + {{ [c.position || c.department, signerPhoneOf(c)].filter(Boolean).join(" · ") }} + + +
@@ -78,6 +95,8 @@ import { ref, computed, watch } from "vue"; import { Plus } from "@element-plus/icons-vue"; import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm"; +import { listContacts } from "@/api/contact"; +import { erpOrganizationApi } from "@/api/organization"; import { useAuthStore } from "@/stores/auth"; const props = defineProps({ @@ -102,6 +121,83 @@ const party = computed(() => props.modelValue); const searching = ref(false); const options = ref([]); +/* ------------------------------ 签约人:人员联动 ------------------------------ */ + +/** 是否为本公司方:锁定方,或 ref_type=0(本公司) */ +const isOwnCompany = computed(() => props.locked || Number(party.value?.ref_type) === 0); + +const contacts = ref([]); +const contactLoading = ref(false); +/** 本公司人员:来自租户组织架构(与组织架构模块同源) */ +const ownEmployees = ref([]); +const ownLoading = ref(false); + +/** 签约人下拉加载态:本公司方取员工加载态,其余取联系人加载态 */ +const signerLoading = computed(() => (isOwnCompany.value ? ownLoading.value : contactLoading.value)); + +/** 联系人电话:手机优先,其次座机 */ +const signerPhoneOf = (c) => c?.mobile || c?.phone || ""; + +const contactPlaceholder = computed(() => { + if (isOwnCompany.value) return "选择本公司人员(也可输入)"; + return party.value?.ref_id ? "选择签约人(也可输入)" : "请先选择客户 / 供应商"; +}); + +/** 拉取本公司人员(在职员工),映射为与联系人一致的字段结构,便于复用下拉渲染 */ +async function fetchOwnEmployees() { + ownLoading.value = true; + try { + const res = await erpOrganizationApi.getEmployeeList({ status: 1 }); + const list = Array.isArray(res) ? res : res?.data || []; + ownEmployees.value = (Array.isArray(list) ? list : []).map((e) => ({ + id: `emp_${e.id}`, + contact_name: e.name || e.account || "", + position: e.position || "", + department: e.department_name || "", + mobile: e.phone || "", + phone: "", + })); + } catch { + ownEmployees.value = []; + } finally { + ownLoading.value = false; + } +} + +/** 拉取签约人候选:本公司方取组织架构人员,其余取关联企业联系人(客户 / 供应商) */ +async function fetchContacts() { + if (isOwnCompany.value) { + if (!ownEmployees.value.length) await fetchOwnEmployees(); + contacts.value = ownEmployees.value; + return; + } + const refID = party.value?.ref_id; + if (!refID) { + contacts.value = []; + return; + } + contactLoading.value = true; + try { + const res = await listContacts({ + related_type: Number(party.value.ref_type) === 2 ? 2 : 1, + related_id: refID, + page: 1, + pageSize: 100, + }); + contacts.value = res?.data?.list || []; + } catch (e) { + contacts.value = []; + } finally { + contactLoading.value = false; + } +} + +/** 选择签约人:自动带出该联系人的联系电话 */ +function handleSignerPick(name) { + const hit = contacts.value.find((c) => c.contact_name === name); + if (hit) party.value.signer_phone = signerPhoneOf(hit); +} + const refTypeText = computed(() => Number(party.value.ref_type) === 2 ? "供应商" : "客户" ); @@ -121,7 +217,11 @@ const ensureOption = () => { watch( () => party.value?.ref_id, - () => ensureOption(), + () => { + ensureOption(); + // 关联企业变化时同步刷新其联系人,供签约人下拉选择 + fetchContacts(); + }, { immediate: true } ); @@ -160,14 +260,20 @@ const handlePick = (id) => { const handleClear = () => { party.value.ref_id = null; party.value.ref_name = ""; + party.value.signer_name = ""; + party.value.signer_phone = ""; options.value = []; + contacts.value = []; }; /** 丙/丁方切换客户 / 供应商来源时清空已选 */ const handleRefTypeChange = () => { party.value.ref_id = null; party.value.ref_name = ""; + party.value.signer_name = ""; + party.value.signer_phone = ""; options.value = []; + contacts.value = []; }; diff --git a/backend/src/views/apps/crm/contract/components/ProductList.vue b/backend/src/views/apps/crm/contract/components/ProductList.vue index 7b46fc9..d7c42d8 100644 --- a/backend/src/views/apps/crm/contract/components/ProductList.vue +++ b/backend/src/views/apps/crm/contract/components/ProductList.vue @@ -170,9 +170,9 @@
添加产品 - +
@@ -388,8 +388,9 @@ function onProductPick(row, val) { const hit = productOptions.value.find((p) => p.product_name === val); if (hit) { row.product_id = hit.id; - // 软件开发产品:带入模块树与人天单价,行类型自动切为 dev - if (hit.line_type === "dev" || hit.dev_tree) { + // 仅当产品行类型为「软件开发」时才带入模块树并切换为开发行。 + // 不能再用 dev_tree 兜底判断:成品可能残留 "[]" 之类的空值(字符串非空即 truthy),会被误判为开发行 + if (hit.line_type === "dev") { row.line_type = "dev"; row.dev_unit_price = Number(hit.dev_unit_price) || 0; row.dev_tree = Array.isArray(hit.dev_tree) @@ -409,6 +410,14 @@ function onProductPick(row, val) { if (!row.unit) row.unit = "项"; return; } + // 命中成品:行类型切回成品并清空开发字段 + // (修正历史版本中被误判为「软件开发」的行:重选产品即可恢复) + row.line_type = "product"; + row.dev_unit_price = 0; + row.dev_tree = []; + row.dev_total_price = 0; + row.dev_auto_total = 0; + if (hit.category) row.category = hit.category; row.spec = hit.spec || ""; row.unit = hit.unit || ""; row.price = hit.price || 0; diff --git a/backend/src/views/apps/crm/contract/components/create.vue b/backend/src/views/apps/crm/contract/components/create.vue index af80a39..ee7f201 100644 --- a/backend/src/views/apps/crm/contract/components/create.vue +++ b/backend/src/views/apps/crm/contract/components/create.vue @@ -179,18 +179,6 @@
- - - - 技术日期到期且回款未回完,合同状态将自动转为「执行异常」 - - @@ -240,6 +228,7 @@ import { ElMessage } from "element-plus"; import { InfoFilled } from "@element-plus/icons-vue"; import { createContract, updateContract } from "@/api/crmContract"; import { getProjectList } from "@/api/crmPipeline"; +import { erpOrganizationApi } from "@/api/organization"; import { useAuthStore } from "@/stores/auth"; import { CONTRACT_CATEGORY_OPTIONS, @@ -294,7 +283,6 @@ const emptyForm = () => ({ sign_date: "", effective_date: "", expire_date: "", - tech_date: "", remark: "", status: 1, step: 1, @@ -346,6 +334,24 @@ const ownPartyRoleLabel = computed(() => { const tenantName = computed(() => authStore.user?.tenant_name || "本公司"); +/** + * 本公司主体:取自租户「组织架构」中的企业单位(is_company=1,顶级组织优先), + * 与组织架构模块同源;未配置或接口不可用时回退登录租户名,保证不出现空值。 + */ +const ownCompany = ref(null); +const ownCompanyName = computed(() => ownCompany.value?.org_name || tenantName.value); + +/** 拉取组织架构中的企业单位;异常时清空,交由 tenantName 兜底 */ +const loadOwnCompany = async () => { + try { + const res = await erpOrganizationApi.getCompanys(); + const list = Array.isArray(res) ? res : res?.data || []; + ownCompany.value = list.find((o) => Number(o.parent_id) === 0) || list[0] || null; + } catch { + ownCompany.value = null; + } +}; + const isOwnParty = (role) => role === ownPartyRole.value; const activeRoles = computed(() => PARTY_ROLES.slice(0, Number(form.party_count) || 2)); @@ -454,19 +460,19 @@ const setPartyCount = (v) => { applyOwnParty(); }; -/** 把当前租户写入本公司方 */ +/** 把当前租户(组织架构中的企业单位)写入本公司方 */ const applyOwnParty = () => { const own = parties.value.find((p) => p.role === ownPartyRole.value); if (own) { own.ref_type = 0; own.ref_id = 0; - own.ref_name = tenantName.value; + own.ref_name = ownCompanyName.value; } }; watch( () => props.visible, - (val) => { + async (val) => { if (!val) return; activeStep.value = Math.max(0, Math.min(MAX_STEP, (props.initStep || 1) - 1)); internalId.value = null; @@ -487,7 +493,6 @@ watch( form.sign_date = normalizeDate(props.editData.sign_date); form.effective_date = normalizeDate(props.editData.effective_date); form.expire_date = normalizeDate(props.editData.expire_date); - form.tech_date = normalizeDate(props.editData.tech_date); parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p })); products.value = (props.editData.products || []).map((row, i) => ({ ...row, @@ -516,6 +521,8 @@ watch( } } + // 先取组织架构中的企业单位,再写入本公司方(避免先用登录租户名填充、后被覆盖) + await loadOwnCompany(); // 兜底补齐参与方并写入本公司方 const count = Number(form.party_count) || 2; parties.value = PARTY_ROLES.slice(0, count).map( @@ -576,7 +583,6 @@ const buildPayload = (step, status) => { sign_date: normalizeDate(form.sign_date), effective_date: normalizeDate(form.effective_date), expire_date: normalizeDate(form.expire_date), - tech_date: normalizeDate(form.tech_date), our_role: Number(form.our_role) || 2, party_count: Number(form.party_count) || 2, parties: parties.value.map((p) => ({ ...p })), diff --git a/backend/src/views/apps/crm/contract/components/detail.vue b/backend/src/views/apps/crm/contract/components/detail.vue index 0e4a89e..fa3e03a 100644 --- a/backend/src/views/apps/crm/contract/components/detail.vue +++ b/backend/src/views/apps/crm/contract/components/detail.vue @@ -84,7 +84,7 @@ @@ -306,6 +408,22 @@ onMounted(fetchList); } } +/* 树形子行(回款记录) */ +.record-name { + font-size: 13px; + color: var(--el-text-color-regular); + + .record-remark { + font-size: 12px; + color: var(--el-text-color-secondary); + } +} + +.record-amount { + font-weight: 600; + color: var(--el-color-success); +} + ::deep(.el-table .op-col .cell) { display: flex; flex-wrap: wrap; diff --git a/backend/src/views/apps/crm/product/components/edit.vue b/backend/src/views/apps/crm/product/components/edit.vue index 563a794..de4ee7a 100644 --- a/backend/src/views/apps/crm/product/components/edit.vue +++ b/backend/src/views/apps/crm/product/components/edit.vue @@ -161,11 +161,14 @@ const rules = { product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }], }; -/** 切换行类型:软件开发确保有模块树容器 */ +/** 切换行类型:开发行确保有模块树容器;切回成品时清空开发字段,避免残留脏数据 */ function onLineTypeChange() { if (form.line_type === "dev") { if (!Array.isArray(form.dev_tree)) form.dev_tree = []; if (form.dev_unit_price == null) form.dev_unit_price = 0; + } else { + form.dev_tree = []; + form.dev_unit_price = 0; } } diff --git a/backend/src/views/apps/crm/project/components/detail_basic.vue b/backend/src/views/apps/crm/project/components/detail_basic.vue index 596c0ac..db5b260 100644 --- a/backend/src/views/apps/crm/project/components/detail_basic.vue +++ b/backend/src/views/apps/crm/project/components/detail_basic.vue @@ -1,32 +1,71 @@ + + diff --git a/backend/src/views/apps/crm/project/components/detail_contract.vue b/backend/src/views/apps/crm/project/components/detail_contract.vue index 7ac3cca..cb73af8 100644 --- a/backend/src/views/apps/crm/project/components/detail_contract.vue +++ b/backend/src/views/apps/crm/project/components/detail_contract.vue @@ -16,8 +16,8 @@ @@ -25,12 +25,15 @@
- {{ 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(" · ") }} + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + {{ row.customer_name || "-" }} / {{ row.supplier_name || "-" }} + + {{ row.customer_name || "-" }} + + + - + - + - - - + 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"`