From 68617335f06697f5d47fae6fb1f8a7b77013ea80 Mon Sep 17 00:00:00 2001
From: lzq <357099073@qq.com>
Date: Tue, 3 Nov 2026 17:48:07 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbug?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../contract/components/QuickCreateParty.vue | 1 +
backend/src/views/apps/crm/customer/index.vue | 124 +++++-
.../crm/project/components/detail_contact.vue | 16 +-
.../apps/crm/project/components/edit.vue | 386 +++++++++++++-----
backend/src/views/apps/crm/project/index.vue | 9 +-
backend/src/views/apps/crm/supplier/index.vue | 127 +++++-
go/controllers/backend_crm_project.go | 82 ++--
.../alter_crm_project_customer_ref_type.sql | 33 ++
go/models/tenant_crm_project.go | 28 +-
9 files changed, 645 insertions(+), 161 deletions(-)
create mode 100644 go/docs/sql/alter_crm_project_customer_ref_type.sql
diff --git a/backend/src/views/apps/crm/contract/components/QuickCreateParty.vue b/backend/src/views/apps/crm/contract/components/QuickCreateParty.vue
index 2af85e9..ed14aaa 100644
--- a/backend/src/views/apps/crm/contract/components/QuickCreateParty.vue
+++ b/backend/src/views/apps/crm/contract/components/QuickCreateParty.vue
@@ -166,6 +166,7 @@ async function handleSubmit() {
emit("created", {
...row,
id: createdID || null,
+ ref_type: Number(props.refType) === 2 ? 2 : 1, // 1=客户库 2=供应商库,调用方据此回填关联类型
label: form.name,
// 一并回传联系人信息,供调用方自动带出对接人 / 电话
contact_person: form.contact_person,
diff --git a/backend/src/views/apps/crm/customer/index.vue b/backend/src/views/apps/crm/customer/index.vue
index 98354ea..cbcbf23 100644
--- a/backend/src/views/apps/crm/customer/index.vue
+++ b/backend/src/views/apps/crm/customer/index.vue
@@ -7,14 +7,6 @@
@@ -69,6 +61,26 @@
+
+
+ 已选 {{ multipleSelection.length }} 项
+
+ 批量启用
+
+
+ 批量禁用
+
+
+ 批量转供应商
+
+
+ 批量移入公海
+
+
+ 批量删除
+
+
+
action(row)));
+ const failed = results.filter((item) => item.status === "rejected");
+ const okCount = rows.length - failed.length;
+ if (failed.length === 0) {
+ ElMessage.success(`${actionText}成功(${okCount} 条)`);
+ } else if (okCount === 0) {
+ ElMessage.error(`${actionText}失败:${failed[0].reason?.message || "请稍后重试"}`);
+ } else {
+ ElMessage.warning(`${actionText}完成:成功 ${okCount} 条,失败 ${failed.length} 条`);
+ }
+ multipleSelection.value = [];
+ fetchList();
+ } finally {
+ batchLoading.value = false;
+ }
+}
+
+/** 批量启用 / 禁用 */
+async function handleBatchToggleStatus(status) {
+ const rows = multipleSelection.value;
+ if (!rows.length) return;
+ const action = status === "1" ? "启用" : "禁用";
+ try {
+ await ElMessageBox.confirm(`确定要${action}选中的 ${rows.length} 个客户吗?`, `批量${action}`, {
+ type: "warning",
+ });
+ } catch (e) {
+ return; // 取消
+ }
+ await runBatch(rows, (row) => toggleCrmCustomerStatus(row.id, status), `批量${action}`);
+}
+
+/** 批量删除(软删,不可恢复) */
+async function handleBatchDelete() {
+ const rows = multipleSelection.value;
+ if (!rows.length) return;
+ try {
+ await ElMessageBox.confirm(
+ `确定要删除选中的 ${rows.length} 个客户吗?删除后不可恢复。`,
+ "批量删除",
+ { type: "warning" }
+ );
+ } catch (e) {
+ return; // 取消
+ }
+ await runBatch(rows, (row) => deleteCrmCustomer(row.id), "批量删除");
+}
+
+/** 批量转供应商:同名企业后端只补同步联系人,原客户记录保留 */
+async function handleBatchConvertSupplier() {
+ const rows = multipleSelection.value;
+ if (!rows.length) return;
+ try {
+ await ElMessageBox.confirm(
+ `将选中的 ${rows.length} 个客户的企业资料与联系人复制到供应商库?原客户记录会保留。`,
+ "批量转供应商",
+ { type: "warning" }
+ );
+ } catch (e) {
+ return; // 取消
+ }
+ await runBatch(rows, (row) => convertCustomerToSupplier(row.id), "批量转供应商");
+}
+
function openCreate() {
currentEditData.value = null;
editVisible.value = true;
@@ -630,6 +719,25 @@ async function submitMovePool() {
border: 1px solid var(--el-border-color-lighter);
}
+/* 批量操作栏:勾选行后出现 */
+.batch-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+ padding: 10px 16px;
+ border-radius: 6px;
+ border: 1px solid var(--el-color-primary-light-7);
+ background: var(--el-color-primary-light-9);
+
+ .batch-tip {
+ margin-right: 8px;
+ font-size: 13px;
+ color: var(--el-text-color-regular);
+ }
+}
+
.name-link {
cursor: pointer;
font-weight: 500;
diff --git a/backend/src/views/apps/crm/project/components/detail_contact.vue b/backend/src/views/apps/crm/project/components/detail_contact.vue
index 773e2af..abe3295 100644
--- a/backend/src/views/apps/crm/project/components/detail_contact.vue
+++ b/backend/src/views/apps/crm/project/components/detail_contact.vue
@@ -188,6 +188,12 @@ const downCompany = computed(() => {
if (pt.value === 1 || !p?.supplier_id) return null;
return { type: Number(p.supplier_ref_type) === 1 ? "customer" : "supplier", id: p.supplier_id };
});
+// 上游主体的联系人来源库:customer_ref_type 2=供应商库,其余(含历史数据)按客户库
+const upCompany = computed(() => {
+ const p = props.project;
+ if (!p?.customer_id) return null;
+ return { type: Number(p.customer_ref_type) === 2 ? "supplier" : "customer", id: p.customer_id };
+});
/** 解析项目上的联系人ID集合(JSON 数组字符串 / 数组) */
function parseIds(raw) {
@@ -238,7 +244,7 @@ async function reload() {
const p = props.project;
pickedUp.value = parseIds(p?.upstream_contact_ids);
pickedDown.value = parseIds(p?.downstream_contact_ids);
- if (!p?.customer_id && !downCompany.value) {
+ if (!upCompany.value && !downCompany.value) {
upstreamContacts.value = [];
downstreamContacts.value = [];
emit("count", 0);
@@ -246,9 +252,9 @@ async function reload() {
}
loading.value = true;
const jobs = [];
- if (p?.customer_id) {
+ if (upCompany.value) {
jobs.push(
- loadCompanyContacts("customer", p.customer_id).then((list) => {
+ loadCompanyContacts(upCompany.value.type, upCompany.value.id).then((list) => {
upstreamContacts.value = list;
})
);
@@ -306,9 +312,7 @@ async function openPicker(groupKey) {
await syncPickerSelection();
return;
}
- const company = isUp
- ? { type: "customer", id: props.project?.customer_id }
- : downCompany.value;
+ const company = isUp ? upCompany.value : downCompany.value;
if (!company?.id) return;
picker.loading = true;
picker.list = await loadCompanyContacts(company.type, company.id);
diff --git a/backend/src/views/apps/crm/project/components/edit.vue b/backend/src/views/apps/crm/project/components/edit.vue
index 761146e..6a98cc4 100644
--- a/backend/src/views/apps/crm/project/components/edit.vue
+++ b/backend/src/views/apps/crm/project/components/edit.vue
@@ -75,25 +75,44 @@
{{ hasCounterparty ? "上游信息" : "客户信息" }}
-
-
-
+
+
+
-
-
-
-
-
-
+ 客户
+ 供应商
+
+
+
+
+ {{ c.customer_name }}
+ {{ customerMeta(c) }}
+
+
+
+
+
+
+
@@ -106,14 +125,14 @@
-
+
@@ -128,7 +147,7 @@
-
+
@@ -139,33 +158,42 @@
-
-
applySupplier(null)"
+
+
+
-
- {{ item.supplier_name }}
-
- {{ Number(item.ref_type) === 1 ? "客户" : "供应商" }}
-
-
-
-
-
-
-
+ 客户
+ 供应商
+
+
+ applySupplier(null)"
+ >
+
+
+ {{ item.supplier_name }}
+ {{ supplierMeta(item) }}
+
+
+ {{ Number(item.ref_type) === 1 ? "客户" : "供应商" }}
+
+
+
+
+
+
+
+
@@ -231,21 +259,21 @@
-->
-
+
-
+
@@ -295,6 +323,8 @@ const defaultForm = () => ({
project_type: 1,
customer_id: "",
customer_name: "",
+ // 上游主体来源库:1=客户库(默认) 2=供应商库
+ customer_ref_type: 1,
supplier_id: 0,
supplier_ref_type: 0,
supplier_name: "",
@@ -356,7 +386,14 @@ const profitAmount = computed(
watch(
() => form.project_type,
(val) => {
- if (Number(val) === 2 || Number(val) === 3) return;
+ if (Number(val) === 2 || Number(val) === 3) {
+ // 未选对方主体时按项目类型切默认分类:上下游→供应商,上中游→客户
+ // 候选留到下拉/失焦时再检索,避免与打开弹窗时的回查互相覆盖
+ if (!Number(form.supplier_id)) {
+ counterpartyCategory.value = defaultCounterpartyCategory(val);
+ }
+ return;
+ }
form.supplier_id = 0;
form.supplier_ref_type = 0;
form.supplier_name = "";
@@ -441,6 +478,14 @@ watch(
Object.assign(form, defaultForm(), props.editData);
form.status = Number(props.editData.status) || 1;
form.project_type = Number(props.editData.project_type) || 1;
+ // 上游主体来源库:历史数据没有该字段,统一按客户库处理
+ form.customer_ref_type = Number(props.editData.customer_ref_type) === 2 ? 2 : 1;
+ customerCategory.value = form.customer_ref_type === 2 ? "supplier" : "customer";
+ // 对方主体分类:优先按已关联的库类型回显(1客户 / 2供应商),未关联时按项目类型给默认分类
+ syncCounterpartyCategory(form.supplier_ref_type);
+ if (!Number(form.supplier_ref_type)) {
+ counterpartyCategory.value = defaultCounterpartyCategory(form.project_type);
+ }
// 存量项目兼容:只有旧「项目金额 / 对接人」时回填到上游侧
if (!Number(form.upstream_amount) && Number(props.editData.amount)) {
form.upstream_amount = props.editData.amount;
@@ -462,12 +507,20 @@ watch(
Object.assign(form, defaultForm());
form.owner_user_id = authStore.user?.id ? String(authStore.user.id) : "";
form.owner_user_name = authStore.user?.name || "";
+ // 新增时上游默认客户库,对方主体按项目类型给默认分类(上下游→供应商,上中游→客户)
+ customerCategory.value = "customer";
+ counterpartyCategory.value = defaultCounterpartyCategory(form.project_type);
}
loadUsers();
searchCustomers(form.customer_name || "");
// 对接人候选随所选企业加载(编辑回显时也需带上,赋值未变化时不触发 watch)
loadUpstreamContacts();
loadDownstreamContacts();
+ // 存量项目可能只存了下游主体名称、没有关联库记录:按名称回查一次,命中同名企业则补上关联,
+ // 否则下游对接人无法带出联系人(详情页的下游联系人也需要库关联)
+ if (hasCounterparty.value && !Number(form.supplier_id) && String(form.supplier_name || "").trim()) {
+ applySupplier(null);
+ }
}
}
);
@@ -484,95 +537,182 @@ async function loadUsers() {
}
}
-async function searchCustomers(keyword) {
+/** 上游主体下拉副文案:联系人 · 联系方式(电话/邮箱),方便区分同名企业 */
+function customerMeta(c) {
+ return [c.contact_person, c.contact_phone || c.contact_email].filter(Boolean).join(" · ");
+}
+
+/** 上游主体分类:customer=客户库 / supplier=供应商库(Tab 切换) */
+const customerCategory = ref("customer");
+const customerCategoryLabel = computed(() =>
+ customerCategory.value === "supplier" ? "供应商" : "客户"
+);
+
+// 检索序号:快速输入时丢弃过期响应,避免旧结果覆盖最新结果
+let customerQuerySeq = 0;
+
+/** 按分类 + 关键字请求企业库(上游主体来源:客户库 / 供应商库) */
+async function fetchUpstreamParties(keyword, category) {
+ const kw = String(keyword || "").trim();
+ const isSupplier = category === "supplier";
+ const res = isSupplier
+ ? await getCrmSupplierList({ page: 1, pageSize: 20, keyword: kw })
+ : await getCrmCustomerList({ page: 1, pageSize: 20, keyword: kw });
+ return (res?.data?.list || []).map((row) => ({
+ id: row.id,
+ ref_type: isSupplier ? 2 : 1,
+ customer_name: (isSupplier ? row.supplier_name : row.customer_name) || "",
+ contact_person: row.contact_person || "",
+ contact_phone: row.contact_phone || "",
+ contact_email: row.contact_email || "",
+ }));
+}
+
+/** 上游主体候选:随输入实时远程检索当前分类对应的企业库 */
+async function searchCustomers(keyword, category = customerCategory.value) {
customerKeyword.value = String(keyword || "");
+ const seq = ++customerQuerySeq;
customerLoading.value = true;
try {
- const res = await getCrmCustomerList({ page: 1, pageSize: 20, keyword: keyword || "" });
- const data = res?.data || {};
- customerOptions.value = (data.list || []).map((c) => ({ id: c.id, customer_name: c.customer_name }));
+ const list = await fetchUpstreamParties(keyword, category);
+ if (seq !== customerQuerySeq) return; // 过期结果:已有更新的检索在路上
+ if (category === customerCategory.value) customerOptions.value = list;
} catch (e) {
- customerOptions.value = [];
+ if (seq === customerQuerySeq && category === customerCategory.value) customerOptions.value = [];
} finally {
- customerLoading.value = false;
+ if (seq === customerQuerySeq) customerLoading.value = false;
}
}
-/** 选中客户后同步名称;清空时一并清掉,避免把旧名称提交到后端 */
+/** 切换分类:按当前名称重新检索对应库(已选主体保持不变) */
+function onCustomerCategoryChange() {
+ searchCustomers(form.customer_name || "");
+}
+
+/** 选中上游主体后同步名称与来源库;清空时一并清掉,避免把旧名称提交到后端 */
function onCustomerPick(id) {
const hit = customerOptions.value.find((c) => Number(c.id) === Number(id));
form.customer_name = hit ? hit.customer_name : "";
+ form.customer_ref_type = hit ? Number(hit.ref_type) || 1 : 0;
}
-/* --------------------------- 快速新建客户 --------------------------- */
+/* --------------------------- 快速新建上游主体 --------------------------- */
function openCustomerCreate() {
customerCreateVisible.value = true;
}
/**
- * 快速新建客户成功:刷新候选并自动选中新客户,
+ * 快速新建成功:刷新候选并自动选中新企业,
* 同时按新建时填写的信息回填对接人与联系电话(对接人候选随 customer_id 自动加载)。
+ * 新建落在哪个库由当前分类决定(客户库 / 供应商库),据此记录上游来源。
*/
async function onCustomerCreated(row) {
if (!row?.id) return;
- const name = row.customer_name || row.label || "";
- await searchCustomers("");
+ const name = row.customer_name || row.supplier_name || row.label || "";
+ const refType = Number(row.ref_type) || (customerCategory.value === "supplier" ? 2 : 1);
+ customerCategory.value = refType === 2 ? "supplier" : "customer";
+ await searchCustomers(name);
if (!customerOptions.value.some((c) => Number(c.id) === Number(row.id))) {
- customerOptions.value.unshift({ id: row.id, customer_name: name });
+ customerOptions.value.unshift({
+ id: row.id,
+ ref_type: refType,
+ customer_name: name,
+ contact_person: row.contact_person || "",
+ contact_phone: row.contact_phone || "",
+ contact_email: row.contact_email || "",
+ });
}
form.customer_id = Number(row.id);
+ form.customer_ref_type = refType;
form.customer_name = name;
form.upstream_contact = row.contact_person || "";
form.upstream_phone = row.contact_phone || "";
}
-/* --------------------------- 下游主体(供应商 / 客户) --------------------------- */
+/* --------------------------- 下游主体(客户 / 供应商) --------------------------- */
const supplierOptions = ref([]);
-/**
- * 下游主体候选:同时检索供应商库与客户库。
- * 同一家单位在系统里可能以客户身份建档,因此下游既支持选供应商,也支持选客户。
- */
-async function searchSuppliers(keyword) {
+// 检索序号:快速输入时丢弃过期响应,避免旧结果覆盖最新结果
+let supplierQuerySeq = 0;
+
+/** 对方主体分类:customer=客户库 / supplier=供应商库(下游与中游共用,Tab 切换) */
+const counterpartyCategory = ref("supplier");
+const counterpartyCategoryLabel = computed(() =>
+ counterpartyCategory.value === "supplier" ? "供应商" : "客户"
+);
+
+/** 按库类型(1客户 2供应商)同步当前分类;未关联库时保持原分类 */
+function syncCounterpartyCategory(refType) {
+ const t = Number(refType) || 0;
+ if (t === 1) counterpartyCategory.value = "customer";
+ else if (t === 2) counterpartyCategory.value = "supplier";
+}
+
+/** 未选对方主体时的默认分类:上下游项目默认供应商,上中游项目默认客户 */
+function defaultCounterpartyCategory(projectType) {
+ return Number(projectType) === 3 ? "customer" : "supplier";
+}
+
+/** 切换分类:按当前输入重新检索该分类候选(已关联的企业保持不变) */
+function onCounterpartyCategoryChange() {
+ searchSuppliers(form.supplier_name || "");
+}
+
+/** 按分类 + 关键字请求企业库(下游主体来源:客户库 / 供应商库) */
+async function fetchCounterparties(keyword, category) {
const kw = String(keyword || "").trim();
+ const isSupplier = category === "supplier";
+ const res = isSupplier
+ ? await getCrmSupplierList({ page: 1, pageSize: 50, keyword: kw })
+ : await getCrmCustomerList({ page: 1, pageSize: 50, keyword: kw });
+ return (res?.data?.list || []).map((row) => ({
+ id: row.id,
+ ref_type: isSupplier ? 2 : 1,
+ supplier_name: (isSupplier ? row.supplier_name : row.customer_name) || "",
+ contact_person: row.contact_person || "",
+ contact_phone: row.contact_phone || "",
+ }));
+}
+
+/**
+ * 对方主体候选:按当前分类 + 关键字实时检索对应企业库(服务端搜索,不受「只加载一页」限制)。
+ * 返回本次检索结果;若期间有更新的检索发出则返回 null,由最新一次检索负责渲染。
+ */
+async function searchSuppliers(keyword, category = counterpartyCategory.value) {
+ const seq = ++supplierQuerySeq;
try {
- const [supRes, cusRes] = await Promise.all([
- getCrmSupplierList({ page: 1, pageSize: 50, keyword: kw }),
- getCrmCustomerList({ page: 1, pageSize: 50, keyword: kw }),
- ]);
- const suppliers = (supRes?.data?.list || []).map((s) => ({
- id: s.id,
- ref_type: 2,
- supplier_name: s.supplier_name || "",
- }));
- const customers = (cusRes?.data?.list || []).map((c) => ({
- id: c.id,
- ref_type: 1,
- supplier_name: c.customer_name || "",
- }));
- supplierOptions.value = [...suppliers, ...customers];
+ const list = await fetchCounterparties(keyword, category);
+ if (seq !== supplierQuerySeq) return null; // 过期结果:已有更新的检索在路上
+ if (category === counterpartyCategory.value) supplierOptions.value = list;
+ return list;
} catch {
- supplierOptions.value = [];
+ if (seq === supplierQuerySeq && category === counterpartyCategory.value) supplierOptions.value = [];
+ return [];
}
}
-/** 供应商名称输入建议:本地模糊过滤已加载候选(自由输入同样生效) */
-function querySuppliers(query, cb) {
- const kw = String(query || "").trim().toLowerCase();
- const list = (supplierOptions.value || []).filter(
- (s) => !kw || String(s.supplier_name || "").toLowerCase().includes(kw)
- );
+/** 下游主体副文案:联系人 · 联系方式,便于确认选中的是哪家企业 */
+function supplierMeta(item) {
+ return [item?.contact_person, item?.contact_phone].filter(Boolean).join(" · ");
+}
+
+/** 输入建议:随输入实时远程检索当前分类(空关键字展示最新候选,自由输入同样生效) */
+async function querySuppliers(query, cb) {
+ const list = await searchSuppliers(query);
+ if (!list) return; // 已有更新的检索在路上,等它回调
// 附加 value 字段:兼容 autocomplete 默认以 value 作为选中值
- cb(list.slice(0, 50).map((s) => ({ ...s, value: s.supplier_name })));
+ cb(list.map((s) => ({ ...s, value: s.supplier_name })));
}
/**
* 选中的库中主体:记录来源类型(1客户 / 2供应商)与 ID,并写入标准名称;
* 手工输入且未命中库时清空关联,仅按名称保存。
+ * 手工输入的名称不在当前分类候选里时,先按当前分类回查,仍未命中再查另一个分类,
+ * 精确命中同名企业则自动关联并切到对应分类,确保下游对接人能正常带出联系人。
*/
-function applySupplier(item) {
+async function applySupplier(item) {
if (item && item.id) {
form.supplier_id = item.id;
form.supplier_ref_type = Number(item.ref_type) || 0;
@@ -580,7 +720,30 @@ function applySupplier(item) {
return;
}
const name = String(form.supplier_name || "").trim();
- const hit = supplierOptions.value.find((s) => s.supplier_name === name);
+ if (!name) {
+ form.supplier_id = 0;
+ form.supplier_ref_type = 0;
+ return;
+ }
+ let hit = supplierOptions.value.find((s) => s.supplier_name === name);
+ if (!hit) {
+ const current = counterpartyCategory.value;
+ const list = (await searchSuppliers(name, current)) || supplierOptions.value;
+ // 输入内容在检索期间被改动:放弃本次绑定,避免关联到旧名称的企业
+ if (String(form.supplier_name || "").trim() !== name) return;
+ hit = list.find((s) => s.supplier_name === name);
+ if (!hit) {
+ // 当前分类没有同名企业:再到另一个库查一次(同一家单位可能建在另一侧)
+ const other = current === "supplier" ? "customer" : "supplier";
+ const otherList = (await searchSuppliers(name, other)) || [];
+ if (String(form.supplier_name || "").trim() !== name) return;
+ hit = otherList.find((s) => s.supplier_name === name);
+ if (hit) {
+ counterpartyCategory.value = other;
+ supplierOptions.value = otherList;
+ }
+ }
+ }
if (hit) {
form.supplier_id = hit.id;
form.supplier_ref_type = Number(hit.ref_type) || 0;
@@ -590,9 +753,9 @@ function applySupplier(item) {
}
}
-/* --------------------------- 快速新建中游企业 --------------------------- */
+/* --------------------------- 快速新建(下游主体 / 中游企业) --------------------------- */
-// 中游企业可能不在客户 / 供应商库中:支持直接新建到客户库后自动关联
+// 企业可能不在客户 / 供应商库中:按当前分类直接新建并自动关联
const counterpartyCreateVisible = ref(false);
function openCounterpartyCreate() {
@@ -600,15 +763,17 @@ function openCounterpartyCreate() {
}
/**
- * 快速新建中游企业成功:按新建记录回填中游企业(库关联 + 标准名称),
- * 并用新建时填写的联系人带出中游对接人 / 电话(联系人候选随 supplier_id 自动加载)。
+ * 快速新建成功:按新建记录回填对方主体(库关联 + 标准名称 + 来源分类),
+ * 并用新建时填写的联系人带出下游 / 中游对接人、电话(联系人候选随 supplier_id 自动加载)。
*/
async function onCounterpartyCreated(row) {
if (!row?.id) return;
const name = row.customer_name || row.supplier_name || row.label || "";
+ const refType = Number(row.ref_type) || (counterpartyCategory.value === "supplier" ? 2 : 1);
+ syncCounterpartyCategory(refType);
await searchSuppliers(name);
form.supplier_id = Number(row.id);
- form.supplier_ref_type = Number(row.ref_type) || 1; // 新建的企业落在客户库
+ form.supplier_ref_type = refType;
form.supplier_name = name;
form.downstream_contact = row.contact_person || "";
form.downstream_phone = row.contact_phone || "";
@@ -636,7 +801,7 @@ async function fetchContactsOf(relatedType, relatedID) {
}
}
-/** 上游对接人候选:所选客户(上游)的联系人 */
+/** 上游对接人候选:所选上游主体(客户 / 供应商)的联系人 */
async function loadUpstreamContacts() {
const id = Number(form.customer_id) || 0;
if (!id) {
@@ -645,7 +810,7 @@ async function loadUpstreamContacts() {
}
upstreamContactLoading.value = true;
try {
- upstreamContacts.value = await fetchContactsOf(1, id);
+ upstreamContacts.value = await fetchContactsOf(Number(form.customer_ref_type) === 2 ? 2 : 1, id);
} finally {
upstreamContactLoading.value = false;
}
@@ -678,8 +843,8 @@ function onDownstreamContactPick(name) {
if (hit) form.downstream_phone = hit.mobile || hit.phone || "";
}
-// 客户 / 下游主体变化时刷新对接人候选(不清空已选值,兼容历史手工数据)
-watch(() => form.customer_id, loadUpstreamContacts);
+// 上游 / 下游主体变化时刷新对接人候选(不清空已选值,兼容历史手工数据)
+watch(() => [form.customer_id, form.customer_ref_type], loadUpstreamContacts);
watch(() => [form.supplier_id, form.supplier_ref_type], loadDownstreamContacts);
function handleClose() {
@@ -708,6 +873,7 @@ async function handleSubmit() {
project_no: form.project_no,
project_type: Number(form.project_type) || 1,
customer_id: Number(form.customer_id) || 0,
+ customer_ref_type: Number(form.customer_ref_type) || 1, // 1客户库 2供应商库
customer_name: form.customer_name,
supplier_id: Number(form.supplier_id) || 0,
supplier_ref_type: Number(form.supplier_ref_type) || 0,
@@ -778,6 +944,14 @@ async function handleSubmit() {
}
}
+/* 对方主体:客户 / 供应商分类切换 + 搜索框 */
+.counterparty-picker {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+}
+
.opt-meta {
margin-left: 8px;
font-size: 12px;
diff --git a/backend/src/views/apps/crm/project/index.vue b/backend/src/views/apps/crm/project/index.vue
index c4d783b..68a39c1 100644
--- a/backend/src/views/apps/crm/project/index.vue
+++ b/backend/src/views/apps/crm/project/index.vue
@@ -128,7 +128,7 @@
@@ -168,8 +168,9 @@ const editVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
-// 项目联系人(正式库)
+// 项目联系人(正式库):上游主体可能来自客户库或供应商库
const contactBookVisible = ref(false);
+const contactBookCompanyType = ref("customer");
const contactBookCompanyId = ref(null);
const contactBookCompanyName = ref("");
@@ -246,9 +247,11 @@ function onDetailEdit(row) {
function openContactBook(row) {
if (!row.customer_id) {
- ElMessage.warning("该项目未关联正式客户,暂无正式联系人");
+ ElMessage.warning("该项目未关联上游主体,暂无正式联系人");
return;
}
+ // 上游主体可能建在客户库或供应商库(customer_ref_type 2=供应商库)
+ contactBookCompanyType.value = Number(row.customer_ref_type) === 2 ? "supplier" : "customer";
contactBookCompanyId.value = row.customer_id;
contactBookCompanyName.value = row.customer_name || "";
contactBookVisible.value = true;
diff --git a/backend/src/views/apps/crm/supplier/index.vue b/backend/src/views/apps/crm/supplier/index.vue
index 2d689da..e361199 100644
--- a/backend/src/views/apps/crm/supplier/index.vue
+++ b/backend/src/views/apps/crm/supplier/index.vue
@@ -61,8 +61,32 @@
+
+
+ 已选 {{ multipleSelection.length }} 项
+
+ 批量启用
+
+
+ 批量禁用
+
+
+ 批量转客户
+
+
+ 批量删除
+
+
+
-
+
+
{{ row.supplier_name }}
@@ -331,6 +355,10 @@ const contactEditVisible = ref(false);
const contactEditData = ref(null);
const smartAddVisible = ref(false);
+// 批量操作:勾选行 + 执行状态
+const multipleSelection = ref([]);
+const batchLoading = ref(false);
+
const filters = reactive({
keyword: "",
supplier_type: "",
@@ -405,6 +433,84 @@ function openDetail(row) {
detailVisible.value = true;
}
+/* ------------------------------ 批量操作 ------------------------------ */
+
+function handleSelectionChange(val) {
+ multipleSelection.value = val;
+}
+
+/**
+ * 批量执行:逐个复用单条接口,单行失败不影响其他行;
+ * 结束后按成功 / 失败数量给出汇总提示,并刷新列表、清空选中。
+ */
+async function runBatch(rows, action, actionText) {
+ if (!rows.length) return;
+ batchLoading.value = true;
+ try {
+ const results = await Promise.allSettled(rows.map((row) => action(row)));
+ const failed = results.filter((item) => item.status === "rejected");
+ const okCount = rows.length - failed.length;
+ if (failed.length === 0) {
+ ElMessage.success(`${actionText}成功(${okCount} 条)`);
+ } else if (okCount === 0) {
+ ElMessage.error(`${actionText}失败:${failed[0].reason?.message || "请稍后重试"}`);
+ } else {
+ ElMessage.warning(`${actionText}完成:成功 ${okCount} 条,失败 ${failed.length} 条`);
+ }
+ multipleSelection.value = [];
+ fetchList();
+ } finally {
+ batchLoading.value = false;
+ }
+}
+
+/** 批量启用 / 禁用 */
+async function handleBatchToggleStatus(status) {
+ const rows = multipleSelection.value;
+ if (!rows.length) return;
+ const action = status === "1" ? "启用" : "禁用";
+ try {
+ await ElMessageBox.confirm(`确定要${action}选中的 ${rows.length} 家供应商吗?`, `批量${action}`, {
+ type: "warning",
+ });
+ } catch (e) {
+ return; // 取消
+ }
+ await runBatch(rows, (row) => toggleCrmSupplierStatus(row.id, status), `批量${action}`);
+}
+
+/** 批量删除(软删,不可恢复) */
+async function handleBatchDelete() {
+ const rows = multipleSelection.value;
+ if (!rows.length) return;
+ try {
+ await ElMessageBox.confirm(
+ `确定要删除选中的 ${rows.length} 家供应商吗?删除后不可恢复。`,
+ "批量删除",
+ { type: "warning" }
+ );
+ } catch (e) {
+ return; // 取消
+ }
+ await runBatch(rows, (row) => deleteCrmSupplier(row.id), "批量删除");
+}
+
+/** 批量转客户:同名企业后端只补同步联系人,原供应商记录保留 */
+async function handleBatchConvertCustomer() {
+ const rows = multipleSelection.value;
+ if (!rows.length) return;
+ try {
+ await ElMessageBox.confirm(
+ `将选中的 ${rows.length} 家供应商的企业资料与联系人复制到客户库?原供应商记录会保留。`,
+ "批量转客户",
+ { type: "warning" }
+ );
+ } catch (e) {
+ return; // 取消
+ }
+ await runBatch(rows, (row) => convertSupplierToCustomer(row.id), "批量转客户");
+}
+
/**
* 供应商转客户:把供应商的企业资料与联系人复制一套到客户库。
* 同一家单位可能既是供应商又是客户,原供应商记录保留;客户库已有同名企业时只补同步联系人,不重复建档。
@@ -561,6 +667,25 @@ async function handleContactDelete(row) {
border: 1px solid var(--el-border-color-lighter);
}
+/* 批量操作栏:勾选行后出现 */
+.batch-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+ padding: 10px 16px;
+ border-radius: 6px;
+ border: 1px solid var(--el-color-primary-light-7);
+ background: var(--el-color-primary-light-9);
+
+ .batch-tip {
+ margin-right: 8px;
+ font-size: 13px;
+ color: var(--el-text-color-regular);
+ }
+}
+
.name-link {
cursor: pointer;
font-weight: 500;
diff --git a/go/controllers/backend_crm_project.go b/go/controllers/backend_crm_project.go
index cf4a1e7..f195f06 100644
--- a/go/controllers/backend_crm_project.go
+++ b/go/controllers/backend_crm_project.go
@@ -51,6 +51,7 @@ type projectPayload struct {
// 3=上中游项目(上游 A 公司 + 中游 B 公司,下游固定为本公司)
ProjectType int8 `json:"project_type"`
CustomerID uint64 `json:"customer_id"`
+ CustomerRefType int8 `json:"customer_ref_type"` // 上游主体来源:1客户 2供应商(缺省按客户库)
CustomerName string `json:"customer_name"`
SupplierID uint64 `json:"supplier_id"`
SupplierRefType int8 `json:"supplier_ref_type"` // 对方主体来源:1客户 2供应商(下游 / 中游)
@@ -68,7 +69,7 @@ type projectPayload struct {
// Reason 异常原因:状态改为「异常(5)」时必填,会写入操作日志便于追溯
Reason string `json:"reason"`
StartDate string `json:"start_date"`
- EndDate string `json:"end_date"`
+ EndDate string `json:"end_date"`
// 上下游对接人:仅姓名 + 电话;Contact* 为旧字段,未传新字段时回退使用
UpstreamContact string `json:"upstream_contact"`
UpstreamPhone string `json:"upstream_phone"`
@@ -107,6 +108,40 @@ func normalizeProjectFields(p *projectPayload) (pt int8, upstream, downstream fl
return
}
+// resolveProjectCustomer 解析项目上游主体:
+// refType=2 关联供应商库(同一家单位可能只以供应商身份建档),其余按客户库(默认);
+// 命中时以库中的 ID 与标准名称为准,未命中(或未选择库记录)时保留手工填写的名称、不保留关联。
+func resolveProjectCustomer(tenantID string, refType int8, refID uint64, name string) (*uint64, int8, string) {
+ name = strings.TrimSpace(name)
+ if refID == 0 {
+ return nil, 0, name
+ }
+ if refType == 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
+ }
+ 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
+}
+
// resolveProjectSupplier 解析项目下游主体:
// refType=2 关联供应商库,refType=1 关联客户库(同一家单位可能以客户身份建档);
// 命中时以库中的 ID 与标准名称为准,未命中(或未选择库记录)时保留手工填写的名称、不保留关联。
@@ -190,7 +225,8 @@ func attachProjectContractPeriod(tenantID string, list []models.TenantCrmProject
// crmProjectNameDuplicated 项目重名校验:同租户 + 同名 + 同一客户(服务主体)才算重复。
// 不同客户的同名项目可正常创建(如两家公司都有「官网建设」项目);
// 未关联客户的项目按「客户为空」分组校验(与同样未关联客户的同名项目视为重复)。
-func crmProjectNameDuplicated(tenantID, name string, customerID *uint64) bool {
+// 上游来源库不同视为不同主体:客户库与供应商库各有一套 ID,需按 customer_ref_type 一并区分。
+func crmProjectNameDuplicated(tenantID, name string, customerID *uint64, customerRefType int8) bool {
name = strings.TrimSpace(name)
if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
return false
@@ -201,6 +237,11 @@ func crmProjectNameDuplicated(tenantID, name string, customerID *uint64) bool {
And("project_name", name)
if customerID != nil && *customerID > 0 {
cond = cond.And("customer_id", *customerID)
+ if customerRefType == 2 {
+ cond = cond.And("customer_ref_type", 2)
+ } else {
+ cond = cond.And("customer_ref_type__in", []int{0, 1})
+ }
} else {
cond = cond.AndCond(orm.NewCondition().Or("customer_id__isnull", true).Or("customer_id", 0))
}
@@ -390,18 +431,8 @@ func (c *BackendCrmProjectController) Create() {
}
tenantID := pipelineTenantID(claims)
- var custIDVal *uint64
- customerName := strings.TrimSpace(p.CustomerName)
- if p.CustomerID > 0 {
- var cust models.TenantCrmCustomer
- if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
- Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
- Filter("delete_time__isnull", true).One(&cust); err == nil {
- cid := cust.ID
- custIDVal = &cid
- customerName = cust.CustomerName
- }
- }
+ // 上游主体:默认客户库,也可从供应商库选择(customer_ref_type=2)
+ custIDVal, custRefType, customerName := resolveProjectCustomer(tenantID, p.CustomerRefType, p.CustomerID, p.CustomerName)
status := p.Status
if status == 0 {
status = 1
@@ -419,6 +450,7 @@ func (c *BackendCrmProjectController) Create() {
ProjectNo: strings.TrimSpace(p.ProjectNo),
ProjectType: pt,
CustomerID: custIDVal,
+ CustomerRefType: custRefType,
CustomerName: customerName,
SupplierID: supplierID,
SupplierRefType: supplierRefType,
@@ -448,7 +480,7 @@ func (c *BackendCrmProjectController) Create() {
}
// 唯一性校验:同一客户(服务主体)下的同名项目不允许重复创建(重复数据请联系管理员转移跟进);
// 客户不同则允许同名创建(不同主体可各自立项,如两家客户都做「官网建设」)
- if crmProjectNameDuplicated(tenantID, proj.ProjectName, proj.CustomerID) {
+ if crmProjectNameDuplicated(tenantID, proj.ProjectName, proj.CustomerID, proj.CustomerRefType) {
pipelineErr(&c.Controller, 409, 409, "该客户下已存在同名项目(可能由其他同事创建),如需继续跟进请联系管理员转移")
return
}
@@ -515,14 +547,11 @@ func (c *BackendCrmProjectController) Update() {
proj.ProjectName = strings.TrimSpace(p.ProjectName)
proj.ProjectNo = strings.TrimSpace(p.ProjectNo)
if p.CustomerID > 0 {
- var cust models.TenantCrmCustomer
- if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
- Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
- Filter("delete_time__isnull", true).One(&cust); err == nil {
- cid := cust.ID
- proj.CustomerID = &cid
- proj.CustomerName = cust.CustomerName
- }
+ // 上游主体:按来源库解析(默认客户库,也支持从供应商库选择)
+ cid, cref, cname := resolveProjectCustomer(tenantID, p.CustomerRefType, p.CustomerID, p.CustomerName)
+ proj.CustomerID = cid
+ proj.CustomerRefType = cref
+ proj.CustomerName = cname
} else if strings.TrimSpace(p.CustomerName) != "" {
proj.CustomerName = strings.TrimSpace(p.CustomerName)
}
@@ -728,7 +757,12 @@ func (c *BackendCrmProjectController) SaveContacts() {
if pt != 2 && pt != 3 {
pt = 1
}
- upIDs := filterCompanyContactIDs(tenantID, "customer", proj.CustomerID, p.UpstreamContactIDs)
+ // 上游主体联系人:按上游来源库取(customer_ref_type=2 供应商库,其余客户库)
+ upCompanyType := "customer"
+ if proj.CustomerRefType == 2 {
+ upCompanyType = "supplier"
+ }
+ upIDs := filterCompanyContactIDs(tenantID, upCompanyType, proj.CustomerID, p.UpstreamContactIDs)
var downIDs []uint64
// 上下游 / 上中游项目:对方主体(下游 / 中游)联系人按 supplier_ref_type 选择对应库
if pt != 1 && proj.SupplierID != nil && *proj.SupplierID > 0 {
diff --git a/go/docs/sql/alter_crm_project_customer_ref_type.sql b/go/docs/sql/alter_crm_project_customer_ref_type.sql
new file mode 100644
index 0000000..a8a1773
--- /dev/null
+++ b/go/docs/sql/alter_crm_project_customer_ref_type.sql
@@ -0,0 +1,33 @@
+-- =============================================================
+-- 项目上游主体支持从「供应商库」选择(CRM 项目管理)
+--
+-- 背景:上游主体(customer_id / customer_name)原本固定取客户库;
+-- 同一家单位在系统里可能只以供应商身份建档,因此上游也允许从供应商库选择。
+-- 新增列 yz_backend_crm_project.customer_ref_type:
+-- 1 = 客户库(默认,历史数据同此)
+-- 2 = 供应商库
+--
+-- 幂等:MySQL 的 ADD COLUMN 不支持 IF NOT EXISTS,这里先用 information_schema
+-- 判断列是否存在,缺失才执行 ALTER,可重复执行。
+-- 执行:mysql -h -P -u -p < alter_crm_project_customer_ref_type.sql
+-- =============================================================
+
+SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'yz_backend_crm_project'
+ AND COLUMN_NAME = 'customer_ref_type');
+
+SET @sql := IF(@has_col = 0,
+ 'ALTER TABLE `yz_backend_crm_project` ADD COLUMN `customer_ref_type` TINYINT NOT NULL DEFAULT 1 COMMENT ''上游主体来源:1客户 2供应商'' AFTER `customer_name`',
+ 'SELECT ''yz_backend_crm_project.customer_ref_type already exists'' AS msg');
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 结果校验:应返回一行 customer_ref_type
+SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'yz_backend_crm_project'
+ AND COLUMN_NAME = 'customer_ref_type';
diff --git a/go/models/tenant_crm_project.go b/go/models/tenant_crm_project.go
index 75c6804..dfae5bf 100644
--- a/go/models/tenant_crm_project.go
+++ b/go/models/tenant_crm_project.go
@@ -4,17 +4,19 @@ 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"`
+ 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"`
+ // 上游主体来源库:1=客户库(默认,历史数据同此) 2=供应商库(同一家单位可能只以供应商身份建档)
+ CustomerRefType int8 `orm:"column(customer_ref_type);default(1)" json:"customer_ref_type"`
+ 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=上下游项目(上游客户 + 下游供应商)
// 3=上中游项目(上游 A 公司 + 中游 B 公司,下游固定为本公司)
ProjectType int8 `orm:"column(project_type);default(1)" json:"project_type"`
@@ -28,8 +30,8 @@ type TenantCrmProject struct {
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已暂停/5异常
+ 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已暂停/5异常
// 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"`