- {{ 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 @@
刷新
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
@@ -102,13 +123,55 @@
+
+
+
+
+
+
+
+
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 @@
{{ row.supplier_name }}
+
+
+ 也是客户
+
@@ -98,10 +102,11 @@
{{ formatDateTime(row.update_time) }}
-
+
编辑
联系人
+ 转为客户
{{ row.status === '1' ? '禁用' : '启用' }}
@@ -140,6 +145,10 @@
size="560px"
>
+
+ 转为客户
+ 同一家单位可能既是供应商又是客户,转化后原供应商记录保留
+
{{ detailData.supplier_name }}
@@ -240,12 +249,14 @@
-
+
+
+
+ {{ formatMobiles(row.mobiles) || row.mobile || "-" }}
+
-
-
编辑
@@ -284,6 +295,7 @@ import {
getCrmSupplierList,
deleteCrmSupplier,
toggleCrmSupplierStatus,
+ convertSupplierToCustomer,
} from "@/api/crm";
import { listContacts, deleteContact } from "@/api/contact";
import SupplierEdit from "./components/edit.vue";
@@ -301,6 +313,7 @@ import {
statusTag,
formatDate,
formatDateTime,
+ formatMobiles,
} from "../dict";
const loading = ref(false);
@@ -392,6 +405,35 @@ function openDetail(row) {
detailVisible.value = true;
}
+/**
+ * 供应商转客户:把供应商的企业资料与联系人复制一套到客户库。
+ * 同一家单位可能既是供应商又是客户,原供应商记录保留;客户库已有同名企业时只补同步联系人,不重复建档。
+ */
+async function handleConvertCustomer(row) {
+ try {
+ await ElMessageBox.confirm(
+ `将供应商「${row.supplier_name}」的企业资料与联系人复制一套到客户库?原供应商记录会保留,可在客户管理中继续完善。`,
+ "转为客户",
+ { type: "warning", confirmButtonText: "确认转化" }
+ );
+ } catch (e) {
+ return; // 取消
+ }
+ try {
+ const res = await convertSupplierToCustomer(row.id);
+ const data = res?.data || {};
+ const c = data.contacts || {};
+ ElMessage.success(
+ data.existed
+ ? `客户库已有同名企业,已同步联系人(新增 ${c.inserted || 0}、补充 ${c.merged || 0}),未重复建档`
+ : `已转为客户,并同步联系人(新增 ${c.inserted || 0})`
+ );
+ fetchList();
+ } catch (e) {
+ ElMessage.error(e.message || "转化失败");
+ }
+}
+
async function handleToggleStatus(row) {
const newStatus = row.status === "1" ? "0" : "1";
const action = newStatus === "1" ? "启用" : "禁用";
@@ -552,6 +594,18 @@ async function handleContactDelete(row) {
.detail-content {
padding: 4px 8px;
+ .detail-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+ }
+
+ .detail-actions-tip {
+ font-size: 12px;
+ color: var(--el-text-color-secondary);
+ }
+
:deep(.el-descriptions__label) {
width: 130px;
min-width: 130px;
diff --git a/backend/src/views/apps/erp/components/contactBook.vue b/backend/src/views/apps/erp/components/contactBook.vue
index ed7ce81..ed35179 100644
--- a/backend/src/views/apps/erp/components/contactBook.vue
+++ b/backend/src/views/apps/erp/components/contactBook.vue
@@ -21,6 +21,8 @@
主要
+
+
{{ genderText(row.gender) }}
@@ -31,8 +33,6 @@
-
-
{{ formatMobiles(row.mobiles) }}
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) }}
-
+
通讯录
编辑
+ 转为供应商
{{ row.status === '1' ? '禁用' : '启用' }}
@@ -140,6 +143,10 @@
size="560px"
>
+
+ 转为供应商
+ 同一家单位可能既是客户又是供应商,转化后原客户记录保留
+
{{ detailData.customer_name }}
{{ customerTypeText(detailData.customer_type) }}
@@ -206,7 +213,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Cpu } from '@element-plus/icons-vue'
-import { getCustomerList, deleteCustomer, toggleCustomerStatus } from '@/api/customer'
+import { getCustomerList, deleteCustomer, toggleCustomerStatus, convertCustomerToSupplier } from '@/api/customer'
import CustomerEdit from './components/edit.vue'
import ContactBook from '../components/contactBook.vue'
import SmartAddCompany from '../components/smartAddCompany.vue'
@@ -377,6 +384,35 @@ async function handleDelete(row) {
}
}
+/**
+ * 客户转供应商:把客户的企业资料与联系人复制一套到供应商库。
+ * 同一家单位可能既是客户又是供应商,原客户记录保留;供应商库已有同名企业时只补同步联系人,不重复建档。
+ */
+async function handleConvertSupplier(row) {
+ try {
+ await ElMessageBox.confirm(
+ `将客户「${row.customer_name}」的企业资料与联系人复制一套到供应商库?原客户记录会保留,可在供应商管理中继续完善。`,
+ '转为供应商',
+ { type: 'warning', confirmButtonText: '确认转化' }
+ )
+ } catch (e) {
+ return // 取消
+ }
+ try {
+ const res = await convertCustomerToSupplier(row.id)
+ const data = res?.data || {}
+ const c = data.contacts || {}
+ ElMessage.success(
+ data.existed
+ ? `供应商库已有同名企业,已同步联系人(新增 ${c.inserted || 0}、补充 ${c.merged || 0}),未重复建档`
+ : `已转为供应商,并同步联系人(新增 ${c.inserted || 0})`
+ )
+ fetchList()
+ } catch (e) {
+ ElMessage.error(e.message || '转化失败')
+ }
+}
+
function customerTypeText(type) {
const map = { '1': '企业', '2': '政府机构', '3': '国企', '4': '教育机构', '5':'个人' }
return map[type] || type
@@ -479,4 +515,16 @@ function statusTag(status) {
display: flex;
justify-content: flex-end;
}
+
+.detail-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.detail-actions-tip {
+ font-size: 12px;
+ color: var(--el-text-color-secondary);
+}
diff --git a/backend/src/views/apps/erp/supplier/index.vue b/backend/src/views/apps/erp/supplier/index.vue
index 21f41f1..ef9d09f 100644
--- a/backend/src/views/apps/erp/supplier/index.vue
+++ b/backend/src/views/apps/erp/supplier/index.vue
@@ -67,6 +67,8 @@
{{ row.supplier_name }}
草稿
+
+ 也是客户
@@ -100,10 +102,11 @@
{{ formatDateTime(row.update_time) }}
-
+
通讯录
编辑
+ 转为客户
{{ row.status === '1' ? '禁用' : '启用' }}
@@ -139,6 +142,10 @@
size="560px"
>
+
+ 转为客户
+ 同一家单位可能既是供应商又是客户,转化后原供应商记录保留
+
{{ detailData.supplier_name }}
{{ supplierTypeText(detailData.supplier_type) }}
@@ -205,7 +212,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Cpu } from '@element-plus/icons-vue'
-import { getSupplierList, deleteSupplier, toggleSupplierStatus } from '@/api/supplier'
+import { getSupplierList, deleteSupplier, toggleSupplierStatus, convertSupplierToCustomer } from '@/api/supplier'
import SupplierEdit from './components/edit.vue'
import ContactBook from '../components/contactBook.vue'
import SmartAddCompany from '../components/smartAddCompany.vue'
@@ -376,6 +383,35 @@ async function handleDelete(row) {
}
}
+/**
+ * 供应商转客户:把供应商的企业资料与联系人复制一套到客户库。
+ * 同一家单位可能既是供应商又是客户,原供应商记录保留;客户库已有同名企业时只补同步联系人,不重复建档。
+ */
+async function handleConvertCustomer(row) {
+ try {
+ await ElMessageBox.confirm(
+ `将供应商「${row.supplier_name}」的企业资料与联系人复制一套到客户库?原供应商记录会保留,可在客户管理中继续完善。`,
+ '转为客户',
+ { type: 'warning', confirmButtonText: '确认转化' }
+ )
+ } catch (e) {
+ return // 取消
+ }
+ try {
+ const res = await convertSupplierToCustomer(row.id)
+ const data = res?.data || {}
+ const c = data.contacts || {}
+ ElMessage.success(
+ data.existed
+ ? `客户库已有同名企业,已同步联系人(新增 ${c.inserted || 0}、补充 ${c.merged || 0}),未重复建档`
+ : `已转为客户,并同步联系人(新增 ${c.inserted || 0})`
+ )
+ fetchList()
+ } catch (e) {
+ ElMessage.error(e.message || '转化失败')
+ }
+}
+
function supplierTypeText(type) {
const map = { '1': '原材料供应商', '2': '设备供应商', '3': '服务供应商', '4': '其他' }
return map[type] || type
@@ -478,4 +514,16 @@ function statusTag(status) {
display: flex;
justify-content: flex-end;
}
+
+.detail-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.detail-actions-tip {
+ font-size: 12px;
+ color: var(--el-text-color-secondary);
+}
diff --git a/backend/src/views/apps/oa/contact/components/contactDetailDialog.vue b/backend/src/views/apps/oa/contact/components/contactDetailDialog.vue
index f43ff29..45d7e59 100644
--- a/backend/src/views/apps/oa/contact/components/contactDetailDialog.vue
+++ b/backend/src/views/apps/oa/contact/components/contactDetailDialog.vue
@@ -41,9 +41,11 @@
联系方式
-
- {{ contact.phone }}
-
+
+
+
-
@@ -124,6 +126,28 @@ const genderLabel = (gender) => {
return map[gender] || '未知'
}
+/** 解析多手机号:JSON 数组字符串优先,缺省回退单个 phone */
+const contactMobiles = computed(() => {
+ const c = props.contact
+ if (!c) return []
+ let arr = []
+ const raw = c.mobiles
+ if (Array.isArray(raw)) {
+ arr = raw
+ } else if (raw !== undefined && raw !== null && String(raw).trim()) {
+ const s = String(raw).trim()
+ try {
+ const parsed = JSON.parse(s)
+ arr = Array.isArray(parsed) ? parsed : [s]
+ } catch (error) {
+ arr = s.split(/[,,、;;\s]+/)
+ }
+ }
+ arr = arr.map((m) => String(m || '').trim()).filter(Boolean)
+ if (arr.length === 0 && c.phone) arr = [String(c.phone).trim()]
+ return arr
+})
+
const handleEdit = () => {
emit('edit', props.contact)
}
diff --git a/backend/src/views/apps/oa/contact/components/contactEditDialog.vue b/backend/src/views/apps/oa/contact/components/contactEditDialog.vue
index 87b2460..22325f6 100644
--- a/backend/src/views/apps/oa/contact/components/contactEditDialog.vue
+++ b/backend/src/views/apps/oa/contact/components/contactEditDialog.vue
@@ -84,15 +84,23 @@
-
-
-
+
+
+
+
+
+
+
+
+
+ 添加手机号({{ form.mobiles.length }}/5)
+
+
+
+
+
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
+import { Plus, Delete } from '@element-plus/icons-vue'
import { getOrganizationList, getPositionList, getPositionDetail } from '@/api/contactOA'
const props = defineProps({
@@ -248,6 +257,8 @@ const emptyForm = () => ({
contact_type: 2,
gender: 0,
phone: '',
+ // 手机号:动态列表,默认一行,点击加号增加(最多 5 个,与后端存储上限一致)
+ mobiles: [''],
work_phone: '',
email: '',
wechat: '',
@@ -273,8 +284,17 @@ const rules = {
contact_type: [
{ required: true, message: '请选择联系人类型', trigger: 'change' }
],
- phone: [
- { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
+ mobiles: [
+ {
+ // 手机号非必填,填写时逐个校验格式
+ validator: (rule, value, callback) => {
+ const list = Array.isArray(value) ? value : [value]
+ const bad = list.map((m) => String(m || '').trim()).find((m) => m && !/^1[3-9]\d{9}$/.test(m))
+ if (bad) return callback(new Error(`手机号格式不正确:${bad}`))
+ callback()
+ },
+ trigger: 'blur'
+ }
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
@@ -367,9 +387,41 @@ const handlePositionChange = (val) => {
form.value.position_title = pos ? pos.position_name : ''
}
+/** 增加一行手机号输入 */
+const addMobile = () => {
+ if (form.value.mobiles.length < 5) form.value.mobiles.push('')
+}
+
+/** 删除一行手机号输入(至少保留一行) */
+const removeMobile = (idx) => {
+ if (form.value.mobiles.length > 1) form.value.mobiles.splice(idx, 1)
+}
+
+/** 解析手机号集合:兼容 JSON 数组字符串 / 分隔符字符串 / 旧数据单个号码(最多 5 个) */
+const parseMobilesArr = (row) => {
+ let arr = []
+ const raw = row?.mobiles
+ if (Array.isArray(raw)) {
+ arr = raw
+ } else if (raw !== undefined && raw !== null && String(raw).trim()) {
+ const s = String(raw).trim()
+ try {
+ const parsed = JSON.parse(s)
+ arr = Array.isArray(parsed) ? parsed : [s]
+ } catch (error) {
+ arr = s.split(/[,,、;;\s]+/)
+ }
+ }
+ arr = arr.map((m) => String(m || '').trim()).filter(Boolean).slice(0, 5)
+ if (arr.length === 0 && row?.phone) arr = [String(row.phone).trim()]
+ return arr.length ? arr : ['']
+}
+
const initForm = () => {
if (props.isEdit && props.formData) {
form.value = { ...props.formData }
+ // 回显手机号:mobiles JSON 数组优先,缺省回退单个 phone
+ form.value.mobiles = parseMobilesArr(props.formData)
} else {
form.value = emptyForm()
}
@@ -380,7 +432,14 @@ const handleSubmit = async () => {
try {
await formRef.value.validate()
submitting.value = true
- const payload = { ...form.value }
+ // 手机号:动态列表去空后存 JSON 字符串(后端 TEXT 存 JSON 数组,最多 5 个),
+ // phone 同步为第一个号码,保持新旧字段一致
+ const valid = form.value.mobiles.map((m) => String(m || '').trim()).filter(Boolean)
+ const payload = {
+ ...form.value,
+ mobiles: valid.length ? JSON.stringify(valid) : '',
+ phone: valid[0] || ''
+ }
emit('save', payload)
} catch {
// validation failed
@@ -424,4 +483,16 @@ watch(() => form.value.contact_type, (type) => {
justify-content: flex-end;
gap: 8px;
}
+
+/* 手机号:动态列表,每行输入框 + 删除按钮 */
+.mobile-list {
+ width: 100%;
+
+ .mobile-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+ }
+}
diff --git a/backend/src/views/apps/oa/contact/index.vue b/backend/src/views/apps/oa/contact/index.vue
index 0234e4c..ad70456 100644
--- a/backend/src/views/apps/oa/contact/index.vue
+++ b/backend/src/views/apps/oa/contact/index.vue
@@ -116,7 +116,9 @@
{{ row.org_name || row.dept_name || '-' }}
-
+
+ {{ formatMobiles(row.mobiles) || row.phone || '-' }}
+