修复bug
This commit is contained in:
1 parent
fc8c3ee3ad
commit
68617335f0
9 files changed
+645
-161
No files matched your search
@@ -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,
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
:disabled="!multipleSelection.length"
|
||||
@click="openMovePool(multipleSelection)"
|
||||
>
|
||||
移入公海
|
||||
</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增客户</el-button>
|
||||
<el-button type="success" :icon="Cpu" @click="smartAddVisible = true">智能添加</el-button>
|
||||
</div>
|
||||
@@ -69,6 +61,26 @@
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作:勾选行后出现,所有操作都按所选记录批量执行 -->
|
||||
<div v-if="multipleSelection.length" class="batch-bar">
|
||||
<span class="batch-tip">已选 {{ multipleSelection.length }} 项</span>
|
||||
<el-button size="small" type="primary" plain :loading="batchLoading" @click="handleBatchToggleStatus('1')">
|
||||
批量启用
|
||||
</el-button>
|
||||
<el-button size="small" type="info" plain :loading="batchLoading" @click="handleBatchToggleStatus('0')">
|
||||
批量禁用
|
||||
</el-button>
|
||||
<el-button size="small" type="success" plain :loading="batchLoading" @click="handleBatchConvertSupplier">
|
||||
批量转供应商
|
||||
</el-button>
|
||||
<el-button size="small" type="warning" plain @click="openMovePool(multipleSelection)">
|
||||
批量移入公海
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain :loading="batchLoading" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table
|
||||
:data="tableData"
|
||||
@@ -333,6 +345,7 @@ import { Plus, Search, Refresh, Cpu } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getCrmCustomerList,
|
||||
deleteCrmCustomer,
|
||||
toggleCrmCustomerStatus,
|
||||
moveToPool,
|
||||
convertCustomerToSupplier,
|
||||
} from "@/api/crm";
|
||||
@@ -446,6 +459,82 @@ function handleSelectionChange(val) {
|
||||
multipleSelection.value = val;
|
||||
}
|
||||
|
||||
/* ------------------------------ 批量操作 ------------------------------ */
|
||||
|
||||
const batchLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 批量执行:逐个复用单条接口,单行失败不影响其他行;
|
||||
* 结束后按成功 / 失败数量给出汇总提示,并刷新列表、清空选中。
|
||||
*/
|
||||
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) => 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -75,25 +75,44 @@
|
||||
<el-divider content-position="left">{{ hasCounterparty ? "上游信息" : "客户信息" }}</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户" prop="customer_id">
|
||||
<div class="picker-with-add">
|
||||
<el-select
|
||||
v-model="form.customer_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:remote-method="searchCustomers"
|
||||
:loading="customerLoading"
|
||||
placeholder="输入客户名称搜索"
|
||||
style="flex: 1"
|
||||
@change="onCustomerPick"
|
||||
<el-form-item label="客户/供应商" prop="customer_id">
|
||||
<div class="counterparty-picker">
|
||||
<!-- 上游主体也可能只以供应商身份建档:客户库 / 供应商库 Tab 切换 -->
|
||||
<el-radio-group
|
||||
v-model="customerCategory"
|
||||
size="small"
|
||||
@change="onCustomerCategoryChange"
|
||||
>
|
||||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.customer_name" :value="c.id" />
|
||||
</el-select>
|
||||
<!-- 客户库没有时,可直接新建,创建成功后自动选中并回填 -->
|
||||
<el-tooltip content="没有找到?点击新建客户" placement="top">
|
||||
<el-button :icon="Plus" @click="openCustomerCreate" />
|
||||
</el-tooltip>
|
||||
<el-radio-button value="customer">客户</el-radio-button>
|
||||
<el-radio-button value="supplier">供应商</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="picker-with-add">
|
||||
<el-select
|
||||
v-model="form.customer_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:remote-method="searchCustomers"
|
||||
:loading="customerLoading"
|
||||
:placeholder="`输入${customerCategoryLabel}名称搜索`"
|
||||
style="flex: 1"
|
||||
@change="onCustomerPick"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in customerOptions"
|
||||
:key="c.id"
|
||||
:label="c.customer_name"
|
||||
:value="c.id"
|
||||
>
|
||||
<span>{{ c.customer_name }}</span>
|
||||
<span class="opt-meta">{{ customerMeta(c) }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<!-- 客户 / 供应商库没有时可直接新建,创建成功后自动选中并回填 -->
|
||||
<el-tooltip :content="`没有找到?点击新建${customerCategoryLabel}`" placement="top">
|
||||
<el-button :icon="Plus" @click="openCustomerCreate" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -106,14 +125,14 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="hasCounterparty ? '上游对接人' : '对接人'" prop="upstream_contact">
|
||||
<!-- 对接人从该客户已有联系人中带出(自动带出电话),不支持手工输入 -->
|
||||
<!-- 对接人从上游主体(客户 / 供应商)已有联系人中带出(自动带出电话),不支持手工输入 -->
|
||||
<el-select
|
||||
v-model="form.upstream_contact"
|
||||
filterable
|
||||
clearable
|
||||
:loading="upstreamContactLoading"
|
||||
:disabled="!form.customer_id"
|
||||
:placeholder="form.customer_id ? '选择客户联系人' : '请先选择客户'"
|
||||
:placeholder="form.customer_id ? '选择上游联系人' : '请先选择客户/供应商'"
|
||||
style="width: 100%"
|
||||
@change="onUpstreamContactPick"
|
||||
>
|
||||
@@ -128,7 +147,7 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="hasCounterparty ? '上游联系电话' : '联系电话'" prop="upstream_phone">
|
||||
<el-input v-model="form.upstream_phone" placeholder="客户方对接人电话" maxlength="20" />
|
||||
<el-input v-model="form.upstream_phone" placeholder="上游对接人电话" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -139,33 +158,42 @@
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="isUpMid ? '中游企业' : '供应商/客户'" prop="supplier_name">
|
||||
<div class="picker-with-add">
|
||||
<el-autocomplete
|
||||
v-model="form.supplier_name"
|
||||
clearable
|
||||
value-key="supplier_name"
|
||||
:fetch-suggestions="querySuppliers"
|
||||
:placeholder="
|
||||
isUpMid
|
||||
? '输入名称搜索供应商库 / 客户库(中游 B 公司),没有则直接填写'
|
||||
: '输入名称搜索供应商库 / 客户库,没有则直接填写'
|
||||
"
|
||||
style="flex: 1"
|
||||
@focus="searchSuppliers(form.supplier_name || '')"
|
||||
@select="applySupplier"
|
||||
@blur="() => applySupplier(null)"
|
||||
<div class="counterparty-picker">
|
||||
<!-- 客户库 / 供应商库切换:搜索与新建都按当前分类进行 -->
|
||||
<el-radio-group
|
||||
v-model="counterpartyCategory"
|
||||
size="small"
|
||||
@change="onCounterpartyCategoryChange"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<span>{{ item.supplier_name }}</span>
|
||||
<el-tag size="small" effect="plain" :type="Number(item.ref_type) === 1 ? 'success' : 'warning'">
|
||||
{{ Number(item.ref_type) === 1 ? "客户" : "供应商" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<!-- 中游企业可能不在客户 / 供应商库中:支持直接新建(建到客户库)并自动关联 -->
|
||||
<el-tooltip v-if="isUpMid" content="没有找到?点击新建中游企业" placement="top">
|
||||
<el-button :icon="Plus" @click="openCounterpartyCreate" />
|
||||
</el-tooltip>
|
||||
<el-radio-button value="customer">客户</el-radio-button>
|
||||
<el-radio-button value="supplier">供应商</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="picker-with-add">
|
||||
<el-autocomplete
|
||||
v-model="form.supplier_name"
|
||||
clearable
|
||||
value-key="supplier_name"
|
||||
:fetch-suggestions="querySuppliers"
|
||||
:placeholder="`输入名称搜索${counterpartyCategoryLabel},没有则直接填写`"
|
||||
style="flex: 1"
|
||||
@select="applySupplier"
|
||||
@blur="() => applySupplier(null)"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<span>
|
||||
{{ item.supplier_name }}
|
||||
<span v-if="supplierMeta(item)" class="opt-meta">{{ supplierMeta(item) }}</span>
|
||||
</span>
|
||||
<el-tag size="small" effect="plain" :type="Number(item.ref_type) === 1 ? 'success' : 'warning'">
|
||||
{{ Number(item.ref_type) === 1 ? "客户" : "供应商" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<!-- 客户 / 供应商库都没有时可直接新建并自动关联(下游、中游都支持) -->
|
||||
<el-tooltip :content="`没有找到?点击新建${counterpartyCategoryLabel}`" placement="top">
|
||||
<el-button :icon="Plus" @click="openCounterpartyCreate" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -231,21 +259,21 @@
|
||||
</el-divider>
|
||||
<ProductList :products="form.products" /> -->
|
||||
|
||||
<!-- 快速新建客户:创建成功后自动选中并回填项目客户信息 -->
|
||||
<!-- 快速新建上游主体:按当前分类建到客户库或供应商库,创建成功后自动选中并回填 -->
|
||||
<QuickCreateParty
|
||||
v-model:visible="customerCreateVisible"
|
||||
:ref-type="1"
|
||||
:ref-type="customerCategory === 'supplier' ? 2 : 1"
|
||||
:preset-name="customerKeyword"
|
||||
associate-tip="项目客户"
|
||||
:associate-tip="`项目${customerCategoryLabel}`"
|
||||
@created="onCustomerCreated"
|
||||
/>
|
||||
|
||||
<!-- 快速新建中游企业:中游企业可能不在客户 / 供应商库中,创建成功后自动选中并回填中游信息 -->
|
||||
<!-- 快速新建下游主体 / 中游企业:按当前分类建到客户库或供应商库,创建成功后自动选中并回填 -->
|
||||
<QuickCreateParty
|
||||
v-model:visible="counterpartyCreateVisible"
|
||||
:ref-type="1"
|
||||
:ref-type="counterpartyCategory === 'supplier' ? 2 : 1"
|
||||
:preset-name="form.supplier_name"
|
||||
associate-tip="项目中游企业"
|
||||
:associate-tip="isUpMid ? '项目中游企业' : '项目下游主体'"
|
||||
@created="onCounterpartyCreated"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
<!-- 项目联系人:直接读写正式公司联系人库(与 ERP/CRM 客户通讯录共用同一份数据) -->
|
||||
<ContactBook
|
||||
v-model:visible="contactBookVisible"
|
||||
company-type="customer"
|
||||
:company-type="contactBookCompanyType"
|
||||
:company-id="contactBookCompanyId"
|
||||
:company-name="contactBookCompanyName"
|
||||
/>
|
||||
@@ -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;
|
||||
|
||||
@@ -61,8 +61,32 @@
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作:勾选行后出现,所有操作都按所选记录批量执行 -->
|
||||
<div v-if="multipleSelection.length" class="batch-bar">
|
||||
<span class="batch-tip">已选 {{ multipleSelection.length }} 项</span>
|
||||
<el-button size="small" type="primary" plain :loading="batchLoading" @click="handleBatchToggleStatus('1')">
|
||||
批量启用
|
||||
</el-button>
|
||||
<el-button size="small" type="info" plain :loading="batchLoading" @click="handleBatchToggleStatus('0')">
|
||||
批量禁用
|
||||
</el-button>
|
||||
<el-button size="small" type="success" plain :loading="batchLoading" @click="handleBatchConvertCustomer">
|
||||
批量转客户
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain :loading="batchLoading" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border row-key="id">
|
||||
<el-table
|
||||
:data="tableData"
|
||||
stripe
|
||||
border
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column label="供应商名称" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="name-link" @click="openDetail(row)">{{ row.supplier_name }}</span>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <host> -P <port> -u <user> -p <db> < 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';
|
||||
@@ -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"`
|
||||
|
||||
Reference in new issue
Block a user