From a26c98b92b47b5469d142523a73757805bcf63c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Mon, 14 Sep 2026 16:15:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E9=87=8F=E6=9B=B4=E6=96=B0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/contactOA.js | 6 +- backend/src/api/crm.js | 16 + backend/src/api/crmContract.js | 4 +- backend/src/api/crmPipeline.js | 15 + backend/src/api/customer.js | 12 + backend/src/api/supplier.js | 12 + .../crm/business/components/detail_attach.vue | 44 +- .../business/components/detail_contact.vue | 68 +- .../views/apps/crm/clue/components/detail.vue | 48 +- .../crm/clue/components/detail_attach.vue | 44 +- .../crm/clue/components/detail_contact.vue | 68 +- .../views/apps/crm/clue/components/edit.vue | 13 + backend/src/views/apps/crm/clue/index.vue | 38 +- .../apps/crm/contact/components/edit.vue | 124 +++- backend/src/views/apps/crm/contact/index.vue | 10 +- .../crm/contract/components/PartySelect.vue | 22 + .../crm/contract/components/ProductList.vue | 72 +- .../contract/components/QuickCreateParty.vue | 44 +- .../apps/crm/contract/components/create.vue | 51 +- .../crm/contract/components/detail_basic.vue | 26 +- .../contract/components/detail_payback.vue | 182 ++++- backend/src/views/apps/crm/contract/index.vue | 114 +++- backend/src/views/apps/crm/customer/index.vue | 62 +- backend/src/views/apps/crm/dict.js | 205 +++++- .../apps/crm/payback/components/detail.vue | 114 +++- backend/src/views/apps/crm/payback/index.vue | 11 +- backend/src/views/apps/crm/product/cate.vue | 55 +- .../apps/crm/product/components/detail.vue | 100 ++- .../apps/crm/product/components/edit.vue | 20 +- backend/src/views/apps/crm/product/index.vue | 11 + .../apps/crm/project/components/detail.vue | 71 +- .../crm/project/components/detail_attach.vue | 48 +- .../crm/project/components/detail_basic.vue | 59 +- .../crm/project/components/detail_contact.vue | 633 ++++++++++-------- .../project/components/detail_contract.vue | 59 +- .../crm/project/components/detail_docs.vue | 219 +++++- .../apps/crm/project/components/edit.vue | 255 +++++-- backend/src/views/apps/crm/project/index.vue | 15 +- .../src/views/apps/crm/setting/unit/index.vue | 77 ++- backend/src/views/apps/crm/supplier/index.vue | 62 +- .../views/apps/erp/components/contactBook.vue | 4 +- backend/src/views/apps/erp/customer/index.vue | 52 +- backend/src/views/apps/erp/supplier/index.vue | 52 +- .../components/contactDetailDialog.vue | 30 +- .../contact/components/contactEditDialog.vue | 91 ++- backend/src/views/apps/oa/contact/index.vue | 28 +- go/controllers/backend_contact.go | 16 + go/controllers/backend_crm_clue.go | 37 +- go/controllers/backend_crm_contact.go | 222 +++++- go/controllers/backend_crm_contract.go | 163 ++++- go/controllers/backend_crm_party_convert.go | 363 ++++++++++ go/controllers/backend_crm_payback.go | 36 + go/controllers/backend_crm_pipeline_common.go | 41 +- go/controllers/backend_crm_product.go | 30 +- .../backend_crm_product_category.go | 91 +++ go/controllers/backend_crm_project.go | 293 +++++++- go/controllers/backend_crm_support.go | 12 +- go/controllers/backend_crm_unit.go | 39 +- go/controllers/backend_erp_customer.go | 54 +- go/controllers/backend_erp_supplier.go | 54 +- .../alter_backend_crm_product_dev_mode.sql | 20 + go/docs/sql/alter_contact_mobiles.sql | 48 ++ .../alter_crm_product_category_default.sql | 21 + go/docs/sql/alter_crm_unit_default.sql | 19 + go/docs/sql/alter_payback_record_item.sql | 16 + go/docs/sql/alter_project_contacts.sql | 21 + go/main.go | 3 + go/models/contact.go | 4 +- go/models/init.go | 25 +- go/models/tenant_crm_clue.go | 2 +- go/models/tenant_crm_contract.go | 6 +- go/models/tenant_crm_entity_contact.go | 4 +- go/models/tenant_crm_payback_record.go | 7 +- go/models/tenant_crm_product.go | 1 + go/models/tenant_crm_product_category.go | 12 +- go/models/tenant_crm_project.go | 14 +- go/models/tenant_crm_unit.go | 12 +- go/routers/backend/backend.go | 8 + go/services/payback_overdue_scheduler.go | 193 ++++++ 79 files changed, 4529 insertions(+), 724 deletions(-) create mode 100644 go/controllers/backend_crm_party_convert.go create mode 100644 go/docs/sql/alter_backend_crm_product_dev_mode.sql create mode 100644 go/docs/sql/alter_contact_mobiles.sql create mode 100644 go/docs/sql/alter_crm_product_category_default.sql create mode 100644 go/docs/sql/alter_crm_unit_default.sql create mode 100644 go/docs/sql/alter_payback_record_item.sql create mode 100644 go/docs/sql/alter_project_contacts.sql create mode 100644 go/services/payback_overdue_scheduler.go diff --git a/backend/src/api/contactOA.js b/backend/src/api/contactOA.js index 04396cc..5217493 100644 --- a/backend/src/api/contactOA.js +++ b/backend/src/api/contactOA.js @@ -72,7 +72,7 @@ export function getContactOrgTree(params) { }) } -// 获取组织架构列表(用于选择器) +// 获取组织架构列表 export function getOrganizationList(params) { return request({ url: '/backend/erp/getOrganization', @@ -81,7 +81,7 @@ export function getOrganizationList(params) { }) } -// 获取职位列表(用于选择器,可按部门筛选) +// 获取职位列表 export function getPositionList(params) { return request({ url: '/backend/erp/getPosition', @@ -90,7 +90,7 @@ export function getPositionList(params) { }) } -// 获取职位详情(编辑联系人时,已选职位若不在当前部门列表内用于回显) +// 获取职位详情 export function getPositionDetail(id) { return request({ url: `/backend/erp/getPositionDetail/${id}`, diff --git a/backend/src/api/crm.js b/backend/src/api/crm.js index e620eec..359194c 100644 --- a/backend/src/api/crm.js +++ b/backend/src/api/crm.js @@ -39,6 +39,14 @@ export function toggleCrmCustomerStatus(id, status) { }); } +/** + * 客户转供应商:把客户档案(含联系人)复制一套到供应商库。 + * 同一家单位可能既是客户又是供应商,因此原客户记录保留;供应商库已有同名企业时不重复建档,只补同步联系人。 + */ +export function convertCustomerToSupplier(id) { + return request({ url: `/backend/erp/customer/${id}/convert-supplier`, method: "post" }); +} + /* -------------------------------- 供应商 -------------------------------- */ export function getCrmSupplierList(params) { @@ -69,6 +77,14 @@ export function toggleCrmSupplierStatus(id, status) { }); } +/** + * 供应商转客户:把供应商档案(含联系人)复制一套到客户库。 + * 同一家单位可能既是供应商又是客户,因此原供应商记录保留;客户库已有同名企业时不重复建档,只补同步联系人。 + */ +export function convertSupplierToCustomer(id) { + return request({ url: `/backend/erp/supplier/${id}/convert-customer`, method: "post" }); +} + /* ------------------------------- 客户公海 ------------------------------- */ /** 公海列表:租户内共享,所有成员可见 */ diff --git a/backend/src/api/crmContract.js b/backend/src/api/crmContract.js index 9a3ee5f..5041ee3 100644 --- a/backend/src/api/crmContract.js +++ b/backend/src/api/crmContract.js @@ -5,7 +5,7 @@ import request from "@/utils/request"; * * 说明: * - 合同绑定项目:project_id 为空即为「无头合同」,否则为「项目合同」; - * - 合同状态 status:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常; + * - 合同状态 status:1=草稿 2=已完成 3=已作废 4=履约中 5=异常; * - 参与方 parties / 产品清单 products / 金额汇总 summary 随主表一并以 JSON 提交, * 后端按需落库;step 记录进度式创建所到达的步骤,支持每步保存续填。 */ @@ -35,7 +35,7 @@ export function deleteContract(id) { return request({ url: `/backend/crm/contract/${id}`, method: "delete" }); } -/** 合同状态流转:status 1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常 */ +/** 合同状态流转:status 1=草稿 2=已完成 3=已作废 4=履约中 5=异常 */ export function changeContractStatus(id, status) { return request({ url: `/backend/crm/contract/${id}/status`, method: "post", data: { status } }); } diff --git a/backend/src/api/crmPipeline.js b/backend/src/api/crmPipeline.js index 533a39c..897fc55 100644 --- a/backend/src/api/crmPipeline.js +++ b/backend/src/api/crmPipeline.js @@ -82,6 +82,21 @@ export function deleteProject(id) { return request({ url: `/backend/crm/project/${id}`, method: "delete" }); } +/** 项目状态流转:仅更新状态(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常) */ +export function changeProjectStatus(id, status) { + return request({ url: `/backend/crm/project/${id}/status`, method: "post", data: { status } }); +} + +/** + * 保存项目联系人:从对应企业通讯录中挑选的联系人ID集合(多选)。 + * - upstream_contact_ids:上游(客户方)选中的联系人ID数组; + * - downstream_contact_ids:下游/中游(对方主体方)选中的联系人ID数组; + * - 单一项目后端会自动清空下游集合,传入不属于对应企业的ID会被过滤。 + */ +export function saveProjectContacts(id, data) { + return request({ url: `/backend/crm/project/${id}/contacts`, method: "post", data }); +} + /* --------------------------- 项目文档(OA 文档库) --------------------------- */ /** diff --git a/backend/src/api/customer.js b/backend/src/api/customer.js index 7412e24..362e1df 100644 --- a/backend/src/api/customer.js +++ b/backend/src/api/customer.js @@ -45,3 +45,15 @@ export function toggleCustomerStatus(id, status) { data: { status } }) } + +/** + * 客户转供应商:把客户档案(含联系人)复制一套到供应商库。 + * 同一家单位可能既是客户又是供应商,因此原客户记录保留; + * 供应商库已有同名企业时不重复建档,只补同步联系人。 + */ +export function convertCustomerToSupplier(id) { + return request({ + url: `/backend/erp/customer/${id}/convert-supplier`, + method: 'post' + }) +} diff --git a/backend/src/api/supplier.js b/backend/src/api/supplier.js index d9a08a2..87384b5 100644 --- a/backend/src/api/supplier.js +++ b/backend/src/api/supplier.js @@ -45,3 +45,15 @@ export function toggleSupplierStatus(id, status) { data: { status } }) } + +/** + * 供应商转客户:把供应商档案(含联系人)复制一套到客户库。 + * 同一家单位可能既是供应商又是客户,因此原供应商记录保留; + * 客户库已有同名企业时不重复建档,只补同步联系人。 + */ +export function convertSupplierToCustomer(id) { + return request({ + url: `/backend/erp/supplier/${id}/convert-customer`, + method: 'post' + }) +} diff --git a/backend/src/views/apps/crm/business/components/detail_attach.vue b/backend/src/views/apps/crm/business/components/detail_attach.vue index 875af71..2e4de34 100644 --- a/backend/src/views/apps/crm/business/components/detail_attach.vue +++ b/backend/src/views/apps/crm/business/components/detail_attach.vue @@ -1,8 +1,11 @@ - + + + @@ -32,7 +34,17 @@ - + +
+
+ + +
+ + 添加手机号({{ form.mobiles.length }}/5) + +
+
@@ -52,13 +64,14 @@ diff --git a/backend/src/views/apps/crm/contact/components/edit.vue b/backend/src/views/apps/crm/contact/components/edit.vue index 77df56a..6a3bb0d 100644 --- a/backend/src/views/apps/crm/contact/components/edit.vue +++ b/backend/src/views/apps/crm/contact/components/edit.vue @@ -73,9 +73,33 @@ - - - + + + +
+
+ + +
+ + 添加手机号({{ form.mobiles.length }}/5) + +
@@ -163,6 +187,7 @@ + + diff --git a/backend/src/views/apps/crm/contact/index.vue b/backend/src/views/apps/crm/contact/index.vue index 79f3b99..1529507 100644 --- a/backend/src/views/apps/crm/contact/index.vue +++ b/backend/src/views/apps/crm/contact/index.vue @@ -73,7 +73,10 @@ - + + + + @@ -143,6 +148,15 @@ const contactPlaceholder = computed(() => { return party.value?.ref_id ? "选择签约人(也可输入)" : "请先选择客户 / 供应商"; }); +/** 已选企业但通讯录里还没有联系人:提示签约人信息会在保存合同后自动写入通讯录 */ +const showContactTip = computed( + () => + !isOwnCompany.value && + !!party.value?.ref_id && + !contactLoading.value && + contacts.value.length === 0 +); + /** 拉取本公司人员(在职员工),映射为与联系人一致的字段结构,便于复用下拉渲染 */ async function fetchOwnEmployees() { ownLoading.value = true; @@ -338,5 +352,13 @@ const handleRefTypeChange = () => { .select-footer { text-align: center; } + + /* 暂无联系人提示:签约人信息保存后自动写入通讯录 */ + .contact-tip { + margin-top: 8px; + font-size: 12px; + line-height: 1.5; + color: var(--el-text-color-secondary); + } } diff --git a/backend/src/views/apps/crm/contract/components/ProductList.vue b/backend/src/views/apps/crm/contract/components/ProductList.vue index d7c42d8..1bb7c74 100644 --- a/backend/src/views/apps/crm/contract/components/ProductList.vue +++ b/backend/src/views/apps/crm/contract/components/ProductList.vue @@ -10,6 +10,15 @@ + + + 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 cb73af8..e318db2 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 @@ @@ -32,8 +32,8 @@ > {{ c.contract_name }} - - {{ contractSideOf(c) === "down" ? "下游" : "上游" }} + + {{ sideLabel(c) }}
- 合同总利润 - ¥{{ formatMoney(summaryOf(c).total_profit) }} + {{ contractProfitLabel(c.our_role) }} + + {{ contractProfitText(c.our_role, summaryOf(c)) }} +
硬件部分金额 @@ -166,6 +169,8 @@ import { contractCategoryText, productCategoryText, productCategoryTag, + contractProfitLabel, + contractProfitText, } from "../../dict"; const props = defineProps({ @@ -173,6 +178,8 @@ const props = defineProps({ projectId: { type: [Number, String], default: null }, // 关联项目名称(用于新建合同时预设) projectName: { type: String, default: "" }, + // 项目类型:1=单一项目(仅上游) 2=上下游项目 + projectType: { type: [Number, String], default: 1 }, }); const emit = defineEmits(["count"]); @@ -187,25 +194,53 @@ const createVisible = ref(false); const editRow = ref(null); const initStep = ref(1); -/** 新建合同时预设的关联项目 */ +/** 新建合同时预设的关联项目(带项目类型,供新建向导给出上中游提示) */ const presetProject = computed(() => - props.projectId ? { id: props.projectId, project_name: props.projectName } : null + props.projectId + ? { id: props.projectId, project_name: props.projectName, project_type: props.projectType } + : null ); const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-"; /** - * 合同在项目中的上下游归属: - * 判定以「我方是收款方还是付款方」为准 —— 参与方中出现供应商(我方下游)即下游合同; - * 其余按我方角色判断:我方为甲方(采购 / 付款方)算下游,为乙方(供货 / 收款方)算上游。 - * 注意:不能只看对方是否在客户库建档,客户也可能是我方的上游供应商。 + * 是否展示合同归属分段标签:上下游项目区分「上游 / 下游」, + * 上中游项目区分「上中游 / 中下游」;单一项目只有一段,不展示。 + */ +const showSideTag = computed(() => [2, 3].includes(Number(props.projectType))); + +/** + * 合同在项目中的归属分段(side:up / down / mid): + * - 上下游项目(type=2):判定以「我方是收款方还是付款方」为准 —— 参与方中出现供应商(我方下游) + * 即下游合同;其余按我方角色判断:我方为甲方(采购 / 付款方)算下游,为乙方(供货 / 收款方)算上游。 + * 注意:不能只看对方是否在客户库建档,客户也可能是我方的上游供应商。 + * - 上中游项目(type=3):我方不参与签约(our_role=0 或参与方中无本公司)即「上中游」 + * (上游与中游主体之间的合同,甲乙双方均非本公司);否则为我方与中游主体之间的「中下游」合同。 */ function contractSideOf(c) { const parties = Array.isArray(c?.parties) ? c.parties : []; + if (Number(props.projectType) === 3) { + const ownIn = Number(c?.our_role) !== 0 && parties.some((p) => Number(p?.ref_type) === 0); + return ownIn ? "down" : "mid"; + } if (parties.some((p) => Number(p?.ref_type) === 2)) return "down"; return Number(c?.our_role) === 1 ? "down" : "up"; } +/** 分段标签文案:上下游项目=上游 / 下游;上中游项目=上中游 / 中下游 */ +function sideLabel(c) { + const side = contractSideOf(c); + if (Number(props.projectType) === 3) return side === "down" ? "中下游" : "上中游"; + return side === "down" ? "下游" : "上游"; +} + +/** 分段标签颜色 */ +function sideTagType(c) { + const side = contractSideOf(c); + if (Number(props.projectType) === 3) return side === "down" ? "warning" : "danger"; + return side === "down" ? "warning" : "success"; +} + /** 金额汇总:优先后端 summary,缺失时按产品清单前端重算 */ function summaryOf(c) { const s = c?.summary; diff --git a/backend/src/views/apps/crm/project/components/detail_docs.vue b/backend/src/views/apps/crm/project/components/detail_docs.vue index 9988068..051f4fe 100644 --- a/backend/src/views/apps/crm/project/components/detail_docs.vue +++ b/backend/src/views/apps/crm/project/components/detail_docs.vue @@ -69,10 +69,31 @@ 刷新 + + + + + + + + + + + + +
- + + + + diff --git a/backend/src/views/apps/crm/supplier/index.vue b/backend/src/views/apps/crm/supplier/index.vue index 41db26c..2d689da 100644 --- a/backend/src/views/apps/crm/supplier/index.vue +++ b/backend/src/views/apps/crm/supplier/index.vue @@ -66,6 +66,10 @@ @@ -98,10 +102,11 @@ - + - + + + + + - - + + @@ -31,8 +33,6 @@
- - diff --git a/backend/src/views/apps/erp/customer/index.vue b/backend/src/views/apps/erp/customer/index.vue index 096f25f..e3c0665 100644 --- a/backend/src/views/apps/erp/customer/index.vue +++ b/backend/src/views/apps/erp/customer/index.vue @@ -68,6 +68,8 @@
{{ row.customer_name }} 草稿 + + 也是供应商
@@ -101,10 +103,11 @@ {{ formatDateTime(row.update_time) }}
- + @@ -100,10 +102,11 @@ {{ formatDateTime(row.update_time) }} - + - + + +