优化项目管理

This commit is contained in:
2026-09-13 22:31:42 +08:00
parent 2f6cdbb3cd
commit a8c09c1db5
30 changed files with 1654 additions and 346 deletions
+6
View File
@@ -42,6 +42,9 @@ service.interceptors.response.use(
}, },
error => { error => {
if (error.response) { if (error.response) {
// 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作),
// 避免页面只显示 "Request failed with status code xxx"
const bizMsg = error.response.data?.msg || error.response.data?.message;
switch (error.response.status) { switch (error.response.status) {
case 401: case 401:
console.error('未授权,请重新登录'); console.error('未授权,请重新登录');
@@ -57,6 +60,9 @@ service.interceptors.response.use(
default: default:
console.error('请求失败,请稍后再试'); console.error('请求失败,请稍后再试');
} }
if (bizMsg) {
return Promise.reject(new Error(bizMsg));
}
} else if (error.request) { } else if (error.request) {
if (error.code === 'ECONNABORTED') { if (error.code === 'ECONNABORTED') {
console.error('请求超时,请稍后重试'); console.error('请求超时,请稍后重试');
@@ -56,6 +56,13 @@
<el-input v-model="form.industry" placeholder="如:互联网、制造业" maxlength="50" /> <el-input v-model="form.industry" placeholder="如:互联网、制造业" maxlength="50" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12">
<el-form-item label="预计金额" prop="amount">
<el-input v-model="form.amount" placeholder="请输入预计金额">
<template #append>元</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="24"> <el-col :span="24">
<el-form-item label="公司地址" prop="address"> <el-form-item label="公司地址" prop="address">
<el-input v-model="form.address" placeholder="请输入公司地址" maxlength="255" /> <el-input v-model="form.address" placeholder="请输入公司地址" maxlength="255" />
@@ -142,6 +149,7 @@ const defaultForm = () => ({
owner_user_id: "", owner_user_id: "",
owner_user_name: "", owner_user_name: "",
industry: "", industry: "",
amount: "",
next_contact_time: "", next_contact_time: "",
clue_level: "2", clue_level: "2",
contact_person: "", contact_person: "",
@@ -220,7 +228,7 @@ async function handleSubmit() {
if (owner) form.owner_user_name = owner.name; if (owner) form.owner_user_name = owner.name;
submitting.value = true; submitting.value = true;
try { try {
const payload = { ...form }; const payload = { ...form, amount: Number(form.amount) || 0 };
if (internalId.value) { if (internalId.value) {
await updateClue(internalId.value, payload); await updateClue(internalId.value, payload);
ElMessage.success("更新成功"); ElMessage.success("更新成功");
@@ -6,7 +6,7 @@
<el-tag v-else-if="party?.ref_name" size="small" type="info" effect="plain"> <el-tag v-else-if="party?.ref_name" size="small" type="info" effect="plain">
{{ party.ref_type === 2 ? "供应商" : "客户" }} {{ party.ref_type === 2 ? "供应商" : "客户" }}
</el-tag> </el-tag>
<span v-if="locked" class="party-company">{{ tenantName }}</span> <span v-if="locked" class="party-company">{{ party?.ref_name || tenantName }}</span>
</div> </div>
<!-- 关联主体:当前租户方锁定,其余模糊搜索客户库 / 供应商库 --> <!-- 关联主体:当前租户方锁定,其余模糊搜索客户库 / 供应商库 -->
@@ -62,13 +62,30 @@
<!-- <div v-if="party.ref_name" class="picked-name">已关联:{{ party.ref_name }}</div> --> <!-- <div v-if="party.ref_name" class="picked-name">已关联:{{ party.ref_name }}</div> -->
</template> </template>
<!-- 签约人 --> <!-- 签约人:本公司方从组织架构人员中选择,其余方从关联企业联系人中选择,均自动带出联系电话;也支持手动输入 -->
<div class="party-row signer-row"> <div class="party-row signer-row">
<el-input <el-select
v-model="party.signer_name" v-model="party.signer_name"
placeholder="签约人姓名" filterable
maxlength="30" allow-create
/> default-first-option
clearable
:loading="signerLoading"
:placeholder="contactPlaceholder"
@change="handleSignerPick"
>
<el-option
v-for="c in contacts"
:key="c.id"
:label="c.contact_name"
:value="c.contact_name"
>
<span class="option-name">{{ c.contact_name }}</span>
<span class="option-sub">
{{ [c.position || c.department, signerPhoneOf(c)].filter(Boolean).join(" · ") }}
</span>
</el-option>
</el-select>
<el-input v-model="party.signer_phone" placeholder="联系电话" maxlength="20" /> <el-input v-model="party.signer_phone" placeholder="联系电话" maxlength="20" />
</div> </div>
</div> </div>
@@ -78,6 +95,8 @@
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import { Plus } from "@element-plus/icons-vue"; import { Plus } from "@element-plus/icons-vue";
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm"; import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
import { listContacts } from "@/api/contact";
import { erpOrganizationApi } from "@/api/organization";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
const props = defineProps({ const props = defineProps({
@@ -102,6 +121,83 @@ const party = computed(() => props.modelValue);
const searching = ref(false); const searching = ref(false);
const options = ref([]); const options = ref([]);
/* ------------------------------ 签约人:人员联动 ------------------------------ */
/** 是否为本公司方:锁定方,或 ref_type=0(本公司) */
const isOwnCompany = computed(() => props.locked || Number(party.value?.ref_type) === 0);
const contacts = ref([]);
const contactLoading = ref(false);
/** 本公司人员:来自租户组织架构(与组织架构模块同源) */
const ownEmployees = ref([]);
const ownLoading = ref(false);
/** 签约人下拉加载态:本公司方取员工加载态,其余取联系人加载态 */
const signerLoading = computed(() => (isOwnCompany.value ? ownLoading.value : contactLoading.value));
/** 联系人电话:手机优先,其次座机 */
const signerPhoneOf = (c) => c?.mobile || c?.phone || "";
const contactPlaceholder = computed(() => {
if (isOwnCompany.value) return "选择本公司人员(也可输入)";
return party.value?.ref_id ? "选择签约人(也可输入)" : "请先选择客户 / 供应商";
});
/** 拉取本公司人员(在职员工),映射为与联系人一致的字段结构,便于复用下拉渲染 */
async function fetchOwnEmployees() {
ownLoading.value = true;
try {
const res = await erpOrganizationApi.getEmployeeList({ status: 1 });
const list = Array.isArray(res) ? res : res?.data || [];
ownEmployees.value = (Array.isArray(list) ? list : []).map((e) => ({
id: `emp_${e.id}`,
contact_name: e.name || e.account || "",
position: e.position || "",
department: e.department_name || "",
mobile: e.phone || "",
phone: "",
}));
} catch {
ownEmployees.value = [];
} finally {
ownLoading.value = false;
}
}
/** 拉取签约人候选:本公司方取组织架构人员,其余取关联企业联系人(客户 / 供应商) */
async function fetchContacts() {
if (isOwnCompany.value) {
if (!ownEmployees.value.length) await fetchOwnEmployees();
contacts.value = ownEmployees.value;
return;
}
const refID = party.value?.ref_id;
if (!refID) {
contacts.value = [];
return;
}
contactLoading.value = true;
try {
const res = await listContacts({
related_type: Number(party.value.ref_type) === 2 ? 2 : 1,
related_id: refID,
page: 1,
pageSize: 100,
});
contacts.value = res?.data?.list || [];
} catch (e) {
contacts.value = [];
} finally {
contactLoading.value = false;
}
}
/** 选择签约人:自动带出该联系人的联系电话 */
function handleSignerPick(name) {
const hit = contacts.value.find((c) => c.contact_name === name);
if (hit) party.value.signer_phone = signerPhoneOf(hit);
}
const refTypeText = computed(() => const refTypeText = computed(() =>
Number(party.value.ref_type) === 2 ? "供应商" : "客户" Number(party.value.ref_type) === 2 ? "供应商" : "客户"
); );
@@ -121,7 +217,11 @@ const ensureOption = () => {
watch( watch(
() => party.value?.ref_id, () => party.value?.ref_id,
() => ensureOption(), () => {
ensureOption();
// 关联企业变化时同步刷新其联系人,供签约人下拉选择
fetchContacts();
},
{ immediate: true } { immediate: true }
); );
@@ -160,14 +260,20 @@ const handlePick = (id) => {
const handleClear = () => { const handleClear = () => {
party.value.ref_id = null; party.value.ref_id = null;
party.value.ref_name = ""; party.value.ref_name = "";
party.value.signer_name = "";
party.value.signer_phone = "";
options.value = []; options.value = [];
contacts.value = [];
}; };
/** 丙/丁方切换客户 / 供应商来源时清空已选 */ /** 丙/丁方切换客户 / 供应商来源时清空已选 */
const handleRefTypeChange = () => { const handleRefTypeChange = () => {
party.value.ref_id = null; party.value.ref_id = null;
party.value.ref_name = ""; party.value.ref_name = "";
party.value.signer_name = "";
party.value.signer_phone = "";
options.value = []; options.value = [];
contacts.value = [];
}; };
</script> </script>
@@ -170,9 +170,9 @@
<div class="add-row"> <div class="add-row">
<el-button :icon="Plus" @click="addRow">添加产品</el-button> <el-button :icon="Plus" @click="addRow">添加产品</el-button>
<span class="add-tip"> <!-- <span class="add-tip">
每行可选「成品」或「软件开发」:成品按数量×单价计价;软件开发点「模块报价」编辑分层模块树(叶子填人天/人天单价,父级自动汇总),统一计入合同总价 每行可选「成品」或「软件开发」:成品按数量×单价计价;软件开发点「模块报价」编辑分层模块树(叶子填人天/人天单价,父级自动汇总),统一计入合同总价
</span> </span> -->
</div> </div>
<!-- 金额汇总 --> <!-- 金额汇总 -->
@@ -388,8 +388,9 @@ function onProductPick(row, val) {
const hit = productOptions.value.find((p) => p.product_name === val); const hit = productOptions.value.find((p) => p.product_name === val);
if (hit) { if (hit) {
row.product_id = hit.id; row.product_id = hit.id;
// 软件开发产品:带入模块树与人天单价,行类型自动切为 dev // 仅当产品行类型为「软件开发」时才带入模块树并切换为开发行。
if (hit.line_type === "dev" || hit.dev_tree) { // 不能再用 dev_tree 兜底判断:成品可能残留 "[]" 之类的空值(字符串非空即 truthy),会被误判为开发行
if (hit.line_type === "dev") {
row.line_type = "dev"; row.line_type = "dev";
row.dev_unit_price = Number(hit.dev_unit_price) || 0; row.dev_unit_price = Number(hit.dev_unit_price) || 0;
row.dev_tree = Array.isArray(hit.dev_tree) row.dev_tree = Array.isArray(hit.dev_tree)
@@ -409,6 +410,14 @@ function onProductPick(row, val) {
if (!row.unit) row.unit = "项"; if (!row.unit) row.unit = "项";
return; return;
} }
// 命中成品:行类型切回成品并清空开发字段
// (修正历史版本中被误判为「软件开发」的行:重选产品即可恢复)
row.line_type = "product";
row.dev_unit_price = 0;
row.dev_tree = [];
row.dev_total_price = 0;
row.dev_auto_total = 0;
if (hit.category) row.category = hit.category;
row.spec = hit.spec || ""; row.spec = hit.spec || "";
row.unit = hit.unit || ""; row.unit = hit.unit || "";
row.price = hit.price || 0; row.price = hit.price || 0;
@@ -179,18 +179,6 @@
</el-row> </el-row>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="24">
<el-form-item label="技术日期" prop="tech_date">
<el-date-picker
v-model="form.tech_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="开发/交付完成日期"
style="width: 240px"
/>
<span class="form-tip">技术日期到期且回款未回完,合同状态将自动转为「执行异常」</span>
</el-form-item>
</el-col>
</el-row> </el-row>
<el-form-item label="备注" prop="remark" style="margin-top: 16px"> <el-form-item label="备注" prop="remark" style="margin-top: 16px">
@@ -240,6 +228,7 @@ import { ElMessage } from "element-plus";
import { InfoFilled } from "@element-plus/icons-vue"; import { InfoFilled } from "@element-plus/icons-vue";
import { createContract, updateContract } from "@/api/crmContract"; import { createContract, updateContract } from "@/api/crmContract";
import { getProjectList } from "@/api/crmPipeline"; import { getProjectList } from "@/api/crmPipeline";
import { erpOrganizationApi } from "@/api/organization";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { import {
CONTRACT_CATEGORY_OPTIONS, CONTRACT_CATEGORY_OPTIONS,
@@ -294,7 +283,6 @@ const emptyForm = () => ({
sign_date: "", sign_date: "",
effective_date: "", effective_date: "",
expire_date: "", expire_date: "",
tech_date: "",
remark: "", remark: "",
status: 1, status: 1,
step: 1, step: 1,
@@ -346,6 +334,24 @@ const ownPartyRoleLabel = computed(() => {
const tenantName = computed(() => authStore.user?.tenant_name || "本公司"); const tenantName = computed(() => authStore.user?.tenant_name || "本公司");
/**
* 本公司主体:取自租户「组织架构」中的企业单位(is_company=1,顶级组织优先),
* 与组织架构模块同源;未配置或接口不可用时回退登录租户名,保证不出现空值。
*/
const ownCompany = ref(null);
const ownCompanyName = computed(() => ownCompany.value?.org_name || tenantName.value);
/** 拉取组织架构中的企业单位;异常时清空,交由 tenantName 兜底 */
const loadOwnCompany = async () => {
try {
const res = await erpOrganizationApi.getCompanys();
const list = Array.isArray(res) ? res : res?.data || [];
ownCompany.value = list.find((o) => Number(o.parent_id) === 0) || list[0] || null;
} catch {
ownCompany.value = null;
}
};
const isOwnParty = (role) => role === ownPartyRole.value; const isOwnParty = (role) => role === ownPartyRole.value;
const activeRoles = computed(() => PARTY_ROLES.slice(0, Number(form.party_count) || 2)); const activeRoles = computed(() => PARTY_ROLES.slice(0, Number(form.party_count) || 2));
@@ -454,19 +460,19 @@ const setPartyCount = (v) => {
applyOwnParty(); applyOwnParty();
}; };
/** 把当前租户写入本公司方 */ /** 把当前租户(组织架构中的企业单位)写入本公司方 */
const applyOwnParty = () => { const applyOwnParty = () => {
const own = parties.value.find((p) => p.role === ownPartyRole.value); const own = parties.value.find((p) => p.role === ownPartyRole.value);
if (own) { if (own) {
own.ref_type = 0; own.ref_type = 0;
own.ref_id = 0; own.ref_id = 0;
own.ref_name = tenantName.value; own.ref_name = ownCompanyName.value;
} }
}; };
watch( watch(
() => props.visible, () => props.visible,
(val) => { async (val) => {
if (!val) return; if (!val) return;
activeStep.value = Math.max(0, Math.min(MAX_STEP, (props.initStep || 1) - 1)); activeStep.value = Math.max(0, Math.min(MAX_STEP, (props.initStep || 1) - 1));
internalId.value = null; internalId.value = null;
@@ -487,7 +493,6 @@ watch(
form.sign_date = normalizeDate(props.editData.sign_date); form.sign_date = normalizeDate(props.editData.sign_date);
form.effective_date = normalizeDate(props.editData.effective_date); form.effective_date = normalizeDate(props.editData.effective_date);
form.expire_date = normalizeDate(props.editData.expire_date); form.expire_date = normalizeDate(props.editData.expire_date);
form.tech_date = normalizeDate(props.editData.tech_date);
parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p })); parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p }));
products.value = (props.editData.products || []).map((row, i) => ({ products.value = (props.editData.products || []).map((row, i) => ({
...row, ...row,
@@ -516,6 +521,8 @@ watch(
} }
} }
// 先取组织架构中的企业单位,再写入本公司方(避免先用登录租户名填充、后被覆盖)
await loadOwnCompany();
// 兜底补齐参与方并写入本公司方 // 兜底补齐参与方并写入本公司方
const count = Number(form.party_count) || 2; const count = Number(form.party_count) || 2;
parties.value = PARTY_ROLES.slice(0, count).map( parties.value = PARTY_ROLES.slice(0, count).map(
@@ -576,7 +583,6 @@ const buildPayload = (step, status) => {
sign_date: normalizeDate(form.sign_date), sign_date: normalizeDate(form.sign_date),
effective_date: normalizeDate(form.effective_date), effective_date: normalizeDate(form.effective_date),
expire_date: normalizeDate(form.expire_date), expire_date: normalizeDate(form.expire_date),
tech_date: normalizeDate(form.tech_date),
our_role: Number(form.our_role) || 2, our_role: Number(form.our_role) || 2,
party_count: Number(form.party_count) || 2, party_count: Number(form.party_count) || 2,
parties: parties.value.map((p) => ({ ...p })), parties: parties.value.map((p) => ({ ...p })),
@@ -84,7 +84,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, watch } from "vue"; import { ref, reactive, watch, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue"; import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue";
import { import {
@@ -127,9 +127,12 @@ const logRef = ref();
const econtractRef = ref(); const econtractRef = ref();
const invoiceRef = ref(); const invoiceRef = ref();
// 同时监听展开状态与目标合同 id:
// 页面被 keep-alive 缓存时,从其他入口再打开另一份合同时 visible 可能一直是 true(未变化),
// 只监听 visible 会导致不重新加载、内容串成上一份合同。
watch( watch(
() => props.visible, [() => props.visible, () => props.contract?.id],
async (val) => { async ([val]) => {
if (!val) return; if (!val) return;
activeTab.value = "basic"; activeTab.value = "basic";
counts.payback = 0; counts.payback = 0;
@@ -146,6 +149,9 @@ watch(
} finally { } finally {
loading.value = false; loading.value = false;
} }
// 抽屉已打开时不会再触发 @opened,这里主动刷新各 Tab 数据(回款 / 附件 / 日志)
await nextTick();
loadAll();
} }
); );
@@ -34,7 +34,6 @@
<el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item> <el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item> <el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item> <el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item>
<el-descriptions-item label="技术日期">{{ formatDateOnly(detail.tech_date) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item> <el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
@@ -39,7 +39,7 @@
<el-table-column label="负责人" width="90"> <el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template> <template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80" align="center"> <el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button> <el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template> </template>
@@ -92,7 +92,23 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right"> <el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="danger" size="small" @click="handleDeleteRecord(row)">删除</el-button> <el-tooltip
:disabled="Number((planOf(row.payback_id) || {}).status) !== 2"
content="所属回款计划已完成(回款已收齐),不允许删除回款记录"
placement="top"
>
<span>
<el-button
link
type="danger"
size="small"
:disabled="Number((planOf(row.payback_id) || {}).status) === 2"
@click="handleDeleteRecord(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
@@ -420,8 +436,12 @@ async function submitReceive() {
} }
} }
/** 删除一条回款记录(会同步回写计划的已回款金额) */ /** 删除一条回款记录(会同步回写计划的已回款金额;所属计划已完成时禁止) */
async function handleDeleteRecord(row) { async function handleDeleteRecord(row) {
if (Number((planOf(row.payback_id) || {}).status) === 2) {
ElMessage.warning("所属回款计划已完成(回款已收齐),不允许删除回款记录");
return;
}
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`, `确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`,
+63 -8
View File
@@ -28,7 +28,7 @@
start-placeholder="开始日期" start-placeholder="开始日期"
end-placeholder="结束日期" end-placeholder="结束日期"
:clearable="false" :clearable="false"
@change="loadStats" @change="onRangeChange"
/> />
</div> </div>
@@ -184,12 +184,14 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from "vue"; import { ref, reactive, onMounted, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue"; import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import { import {
getContractList, getContractList,
getContractStats, getContractStats,
getContractDetail,
} from "@/api/crmContract"; } from "@/api/crmContract";
import ContractCreate from "./components/create.vue"; import ContractCreate from "./components/create.vue";
import ContractDetail from "./components/detail.vue"; import ContractDetail from "./components/detail.vue";
@@ -207,6 +209,9 @@ import {
formatMoney, formatMoney,
} from "../dict"; } from "../dict";
const route = useRoute();
const router = useRouter();
const loading = ref(false); const loading = ref(false);
const tableData = ref([]); const tableData = ref([]);
const createVisible = ref(false); const createVisible = ref(false);
@@ -223,28 +228,33 @@ const filters = reactive({
}); });
const pagination = reactive({ page: 1, pageSize: 20, total: 0 }); const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 统计周期:month/quarter/year/custom(自定义选区间) */ /** 统计周期:month/quarter/year/custom(自定义选区间),默认本年 */
const statsPeriod = ref("month"); const statsPeriod = ref("year");
const statsRange = ref([]); const statsRange = ref([]);
/** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */ /** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */
const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 }); const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 });
async function loadStats() { /** 周期参数:统计与列表共用,保证两者口径一致 */
try { function periodParams() {
const params = { period: statsPeriod.value }; const params = { period: statsPeriod.value };
if (statsPeriod.value === "custom" && statsRange.value && statsRange.value.length === 2) { if (statsPeriod.value === "custom" && statsRange.value && statsRange.value.length === 2) {
params.start_date = statsRange.value[0]; params.start_date = statsRange.value[0];
params.end_date = statsRange.value[1]; params.end_date = statsRange.value[1];
} }
const res = await getContractStats(params); return params;
}
async function loadStats() {
try {
const res = await getContractStats(periodParams());
stats.value = res?.data || stats.value; stats.value = res?.data || stats.value;
} catch { } catch {
/* 统计失败不影响列表 */ /* 统计失败不影响列表 */
} }
} }
/** 切到「自定义」首次未选区间时,默认当前月,避免卡片空值 */ /** 周期变化:列表与统计一并刷新(回到第一页) */
function onPeriodChange(val) { function onPeriodChange(val) {
if (val === "custom" && (!statsRange.value || statsRange.value.length !== 2)) { if (val === "custom" && (!statsRange.value || statsRange.value.length !== 2)) {
const now = new Date(); const now = new Date();
@@ -253,6 +263,15 @@ function onPeriodChange(val) {
const last = new Date(y, now.getMonth() + 1, 0).getDate(); const last = new Date(y, now.getMonth() + 1, 0).getDate();
statsRange.value = [`${y}-${m}-01`, `${y}-${m}-${last}`]; statsRange.value = [`${y}-${m}-01`, `${y}-${m}-${last}`];
} }
pagination.page = 1;
fetchList();
loadStats();
}
/** 自定义区间变化:同样刷新列表与统计 */
function onRangeChange() {
pagination.page = 1;
fetchList();
loadStats(); loadStats();
} }
@@ -261,6 +280,16 @@ onMounted(() => {
loadStats(); loadStats();
}); });
// 页面被 keep-alive 缓存后 onMounted 只执行一次,改为监听 ?id 变化,
// 保证每次从项目详情「合同管理」等入口带 id 跳转进来都能打开目标合同。
watch(
() => route.query.id,
(id) => {
if (id) openByQueryId();
},
{ immediate: true }
);
async function fetchList() { async function fetchList() {
loading.value = true; loading.value = true;
try { try {
@@ -268,6 +297,8 @@ async function fetchList() {
page: pagination.page, page: pagination.page,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
...filters, ...filters,
// 与顶部统计同口径:按所选周期筛选列表
...periodParams(),
}); });
tableData.value = res?.data?.list || []; tableData.value = res?.data?.list || [];
pagination.total = res?.data?.total || 0; pagination.total = res?.data?.total || 0;
@@ -281,6 +312,30 @@ async function fetchList() {
} }
} }
/**
* URL 带 ?id= 时自动打开对应合同详情(供项目详情「合同管理」点击合同名称跳转)。
* 打开后清除 query,避免刷新或返回本页时反复弹出。
*/
async function openByQueryId() {
const id = route.query.id;
if (!id) return;
// 抽屉可能因 keep-alive 缓存仍处于打开状态,先收起再打开,确保按新合同重新加载
detailVisible.value = false;
await nextTick();
try {
const res = await getContractDetail(id);
const row = res?.data;
if (!row?.id) throw new Error("合同不存在或已被删除");
openDetail(row);
} catch (e) {
ElMessage.error(e.message || "打开合同详情失败");
} finally {
const query = { ...route.query };
delete query.id;
router.replace({ path: route.path, query });
}
}
function handleSearch() { function handleSearch() {
pagination.page = 1; pagination.page = 1;
fetchList(); fetchList();
+13 -10
View File
@@ -241,15 +241,16 @@ const rangeText = computed(() => {
/* ------------------------------ 统计卡片 ------------------------------ */ /* ------------------------------ 统计卡片 ------------------------------ */
const statMeta: StatMeta[] = [ const statMeta: StatMeta[] = [
{ key: "newCustomer", label: "新增客户", type: "count", icon: markRaw(UserFilled) }, // 展示顺序:线索 → 商机 → 项目 → 合同 → 客户/联系人 → 回款 → 回访
{ key: "newContact", label: "新增联系人", type: "count", icon: markRaw(Postcard) },
{ key: "newProject", label: "新增项目", type: "count", icon: markRaw(FolderOpened) },
{ key: "newContract", label: "新增合同", type: "count", icon: markRaw(Tickets) },
{ key: "newClue", label: "新增线索", type: "count", icon: markRaw(Aim) }, { key: "newClue", label: "新增线索", type: "count", icon: markRaw(Aim) },
{ key: "newChance", label: "新增商机", type: "count", icon: markRaw(TrendCharts) }, { key: "newChance", label: "新增商机", type: "count", icon: markRaw(TrendCharts) },
{ key: "projectAmount", label: "项目金额", type: "amount", icon: markRaw(Files) }, { key: "newProject", label: "新增项目", type: "count", icon: markRaw(FolderOpened) },
{ key: "contractAmount", label: "合同金额", type: "amount", icon: markRaw(Document) }, { key: "newContract", label: "新增合同", type: "count", icon: markRaw(Tickets) },
{ key: "newCustomer", label: "新增客户", type: "count", icon: markRaw(UserFilled) },
{ key: "newContact", label: "新增联系人", type: "count", icon: markRaw(Postcard) },
{ key: "clueAmount", label: "线索金额", type: "amount", icon: markRaw(Money) },
{ key: "chanceAmount", label: "商机金额", type: "amount", icon: markRaw(Money) }, { key: "chanceAmount", label: "商机金额", type: "amount", icon: markRaw(Money) },
{ key: "projectAmount", label: "项目金额", type: "amount", icon: markRaw(Files) },
{ key: "paymentAmount", label: "回款金额", type: "amount", icon: markRaw(Wallet) }, { key: "paymentAmount", label: "回款金额", type: "amount", icon: markRaw(Wallet) },
{ key: "newVisit", label: "新增回访", type: "count", icon: markRaw(Phone) }, { key: "newVisit", label: "新增回访", type: "count", icon: markRaw(Phone) },
]; ];
@@ -337,6 +338,7 @@ const renderTrendChart = () => {
legend: { legend: {
data: ["新增客户", "新增商机"], data: ["新增客户", "新增商机"],
right: 0, right: 0,
top: 0,
icon: "roundRect", icon: "roundRect",
itemWidth: 10, itemWidth: 10,
itemHeight: 10, itemHeight: 10,
@@ -460,6 +462,7 @@ const renderAmountChart = () => {
legend: { legend: {
data: ["合同金额", "回款金额"], data: ["合同金额", "回款金额"],
right: 0, right: 0,
top: 0,
icon: "roundRect", icon: "roundRect",
itemWidth: 10, itemWidth: 10,
itemHeight: 10, itemHeight: 10,
@@ -682,10 +685,10 @@ onBeforeUnmount(() => {
} }
} }
/* 统计卡片 */ /* 统计卡片:固定一行 6 个 */
.stat-grid { .stat-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(212px, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px; gap: 12px;
margin-top: 12px; margin-top: 12px;
} }
@@ -693,8 +696,8 @@ onBeforeUnmount(() => {
.stat-card { .stat-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
padding: 16px; padding: 14px 12px;
background: var(--el-bg-color); background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
border-radius: 8px; border-radius: 8px;
+2 -1
View File
@@ -290,7 +290,8 @@ export function overdueText(days) {
/** 项目逾期信息:{ overdue, days } */ /** 项目逾期信息:{ overdue, days } */
export function projectOverdue(row) { export function projectOverdue(row) {
const days = overdueDays(row?.end_date); // 项目起止时间以关联合同汇总为准(contract_end_date),兼容历史的手工日期 end_date
const days = overdueDays(row?.contract_end_date || row?.end_date);
const overdue = days > 0 && Number(row?.status) !== 3; const overdue = days > 0 && Number(row?.status) !== 3;
return { overdue, days: overdue ? days : 0 }; return { overdue, days: overdue ? days : 0 };
} }
@@ -16,7 +16,24 @@
<el-button type="primary" link :icon="Edit" @click="emit('edit', detail)">编辑</el-button> <el-button type="primary" link :icon="Edit" @click="emit('edit', detail)">编辑</el-button>
<el-button type="danger" link :icon="Delete" @click="handleDelete" style="margin-left: 0px">删除</el-button> <el-tooltip
:disabled="Number(detail.status) !== 2"
content="该回款计划已完成(回款已收齐),不允许删除"
placement="top"
>
<span>
<el-button
type="danger"
link
:icon="Delete"
:disabled="Number(detail.status) === 2"
@click="handleDelete"
style="margin-left: 0px"
>
删除
</el-button>
</span>
</el-tooltip>
</div> </div>
<el-descriptions :column="2" border class="desc-block"> <el-descriptions :column="2" border class="desc-block">
@@ -78,7 +95,23 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right"> <el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="danger" size="small" @click="handleDeleteRecord(row)">删除</el-button> <el-tooltip
:disabled="Number(detail.status) !== 2"
content="该回款计划已完成(回款已收齐),不允许删除回款记录"
placement="top"
>
<span>
<el-button
link
type="danger"
size="small"
:disabled="Number(detail.status) === 2"
@click="handleDeleteRecord(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
@@ -320,6 +353,10 @@ async function submitRecord() {
} }
async function handleDeleteRecord(row) { async function handleDeleteRecord(row) {
if (Number(detail.value.status) === 2) {
ElMessage.warning("该回款计划已完成(回款已收齐),不允许删除回款记录");
return;
}
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`, `确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`,
@@ -337,6 +374,10 @@ async function handleDeleteRecord(row) {
} }
async function handleDelete() { async function handleDelete() {
if (Number(detail.value.status) === 2) {
ElMessage.warning("该回款计划已完成(回款已收齐),不允许删除");
return;
}
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确定删除合同「${detail.value.contract_name}」的回款计划吗?删除后不可恢复。`, `确定删除合同「${detail.value.contract_name}」的回款计划吗?删除后不可恢复。`,
+150 -32
View File
@@ -60,61 +60,127 @@
</div> </div>
<div class="table-container" v-loading="loading"> <div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border row-key="id"> <el-table
<el-table-column label="状态" width="90" align="center" fixed> :data="tableData"
stripe
border
row-key="__rowKey"
:tree-props="{ children: 'children' }"
>
<!-- 展开 / 折叠 独立成列(el-table 树形图标固定渲染在第一列),避免与「状态」挤在同一格 -->
<el-table-column width="46" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag> <el-tag v-if="row.__isPlan" :type="paybackStatusTag(row.status)" size="small">
{{ paybackStatusText(row.status) }}
</el-tag>
<el-tag v-else size="small" effect="plain" type="success">回款</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="逾期" width="120" align="center" fixed> <el-table-column label="逾期" width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.__isPlan">
<el-tag v-if="paybackPlanOverdue(row).overdue" type="danger" size="small" effect="dark"> <el-tag v-if="paybackPlanOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(paybackPlanOverdue(row).days) }} {{ overdueText(paybackPlanOverdue(row).days) }}
</el-tag> </el-tag>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column>
<el-table-column label="合同名称" min-width="180" show-overflow-tooltip fixed>
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.contract_name || "-" }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="客户名称" min-width="150" show-overflow-tooltip> <el-table-column label="合同名称 / 回款记录" min-width="240" show-overflow-tooltip>
<template #default="{ row }">{{ row.customer_name || "-" }}</template> <template #default="{ row }">
<span v-if="row.__isPlan" class="name-link" @click="openDetail(row)">{{ row.contract_name || "-" }}</span>
<span v-else class="record-name">
{{ row.received_date || "-" }} 回款
<span v-if="row.remark" class="record-remark">({{ row.remark }})</span>
</span>
</template>
</el-table-column>
<el-table-column label="回款进度" width="150" align="center">
<template #default="{ row }">
<el-progress v-if="row.__isPlan" :percentage="paybackPercent(row)" :stroke-width="10" />
</template>
</el-table-column>
<el-table-column label="合同总金额" width="130" align="right">
<template #default="{ row }">{{ row.__isPlan ? formatMoney(row.contract_amount) : "" }}</template>
</el-table-column>
<el-table-column label="已回款" width="130" align="right">
<template #default="{ row }">
<span v-if="row.__isPlan">{{ formatMoney(row.received_amount) }}</span>
<span v-else class="record-amount">¥{{ formatMoney(row.amount) }}</span>
</template>
</el-table-column> </el-table-column>
<el-table-column label="回款周期" width="90" align="center"> <el-table-column label="回款周期" width="90" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag> <el-tag v-if="row.__isPlan" size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="回款方式" width="110" align="center"> <el-table-column label="回款方式" width="110" align="center">
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template> <template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="合同总金额" width="130" align="right"> <el-table-column label="客户名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ formatMoney(row.contract_amount) }}</template> <template #default="{ row }">{{ row.__isPlan ? row.customer_name || "-" : "" }}</template>
</el-table-column>
<el-table-column label="计划回款总额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
</el-table-column>
<el-table-column label="回款进度" width="150" align="center">
<template #default="{ row }">
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
</template>
</el-table-column> </el-table-column>
<el-table-column label="负责人" width="90"> <el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template> <template #default="{ row }">{{ row.__isPlan ? row.owner_user_name || "-" : "" }}</template>
</el-table-column> </el-table-column>
<el-table-column label="提前提醒" width="90" align="center"> <el-table-column label="提前提醒" width="90" align="center">
<template #default="{ row }">{{ row.remind_days ? `${row.remind_days}天` : "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" class-name="op-col" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
{{ row.__isPlan && row.remind_days ? `${row.remind_days}天` : "" }}
</template>
</el-table-column>
<el-table-column label="操作" width="170" align="center" class-name="op-col" fixed="right">
<template #default="{ row }">
<template v-if="row.__isPlan">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button> <el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button> <el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleRowDelete(row)">删除</el-button> <el-tooltip
:disabled="Number(row.status) !== 2"
content="该回款计划已完成(回款已收齐),不允许删除"
placement="top"
>
<span>
<el-button
link
type="danger"
size="small"
:disabled="Number(row.status) === 2"
@click="handleRowDelete(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</template>
<template v-else>
<el-link
v-if="row.receipt_url"
type="primary"
:href="row.receipt_url"
target="_blank"
:underline="false"
style="margin-right: 8px"
>
回执
</el-link>
<el-tooltip
:disabled="Number(row.__planStatus) !== 2"
content="所属回款计划已完成(回款已收齐),不允许删除回款记录"
placement="top"
>
<span>
<el-button
link
type="danger"
size="small"
:disabled="Number(row.__planStatus) === 2"
@click="handleDeleteRecord(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</template>
</template> </template>
</el-table-column> </el-table-column>
<template #empty><el-empty description="暂无回款计划" :image-size="80" /></template> <template #empty><el-empty description="暂无回款计划" :image-size="80" /></template>
@@ -151,7 +217,7 @@
import { ref, reactive, onMounted } from "vue"; import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue"; import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import { getPaybackList, deletePayback } from "@/api/crmPayback"; import { getPaybackList, deletePayback, deletePaybackRecord } from "@/api/crmPayback";
import PaybackCreate from "./components/create.vue"; import PaybackCreate from "./components/create.vue";
import PaybackDetail from "./components/detail.vue"; import PaybackDetail from "./components/detail.vue";
import { import {
@@ -184,7 +250,19 @@ async function fetchList() {
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
...filters, ...filters,
}); });
tableData.value = res?.data?.list || []; // 树形结构:回款计划为父行,其回款记录作为子行。
// 计划与记录来自不同表,id 会重复,故用 __rowKey 保证树形 row-key 唯一。
tableData.value = (res?.data?.list || []).map((p) => ({
...p,
__isPlan: true,
__rowKey: `plan_${p.id}`,
children: (p.children || []).map((r) => ({
...r,
__isPlan: false,
__planStatus: p.status, // 带上所属计划状态,用于禁止删除已完成计划的回款记录
__rowKey: `rec_${r.id}`,
})),
}));
pagination.total = res?.data?.total || 0; pagination.total = res?.data?.total || 0;
summary.value = res?.data?.summary || { total_amount: 0, received_amount: 0, pending_amount: 0 }; summary.value = res?.data?.summary || { total_amount: 0, received_amount: 0, pending_amount: 0 };
} catch (e) { } catch (e) {
@@ -244,8 +322,12 @@ function handleDetailDelete() {
fetchList(); fetchList();
} }
/** 列表行删除:确认后删除并刷新 */ /** 列表行删除:确认后删除并刷新(已完成计划不允许删除) */
async function handleRowDelete(row) { async function handleRowDelete(row) {
if (Number(row.status) === 2) {
ElMessage.warning("该回款计划已完成(回款已收齐),不允许删除");
return;
}
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确定删除合同「${row.contract_name}」的回款计划吗?删除后不可恢复。`, `确定删除合同「${row.contract_name}」的回款计划吗?删除后不可恢复。`,
@@ -260,6 +342,26 @@ async function handleRowDelete(row) {
} }
} }
/** 删除子行的回款记录(同步回写计划已回款金额;所属计划已完成时禁止) */
async function handleDeleteRecord(row) {
if (Number(row.__planStatus) === 2) {
ElMessage.warning("所属回款计划已完成(回款已收齐),不允许删除回款记录");
return;
}
try {
await ElMessageBox.confirm(
`确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`,
"删除确认",
{ type: "warning" }
);
await deletePaybackRecord(row.id);
ElMessage.success("删除成功");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
onMounted(fetchList); onMounted(fetchList);
</script> </script>
@@ -306,6 +408,22 @@ onMounted(fetchList);
} }
} }
/* 树形子行(回款记录) */
.record-name {
font-size: 13px;
color: var(--el-text-color-regular);
.record-remark {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.record-amount {
font-weight: 600;
color: var(--el-color-success);
}
::deep(.el-table .op-col .cell) { ::deep(.el-table .op-col .cell) {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -161,11 +161,14 @@ const rules = {
product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }], product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
}; };
/** 切换行类型:软件开发确保有模块树容器 */ /** 切换行类型:开发行确保有模块树容器;切回成品时清空开发字段,避免残留脏数据 */
function onLineTypeChange() { function onLineTypeChange() {
if (form.line_type === "dev") { if (form.line_type === "dev") {
if (!Array.isArray(form.dev_tree)) form.dev_tree = []; if (!Array.isArray(form.dev_tree)) form.dev_tree = [];
if (form.dev_unit_price == null) form.dev_unit_price = 0; if (form.dev_unit_price == null) form.dev_unit_price = 0;
} else {
form.dev_tree = [];
form.dev_unit_price = 0;
} }
} }
@@ -1,32 +1,71 @@
<template> <template>
<el-descriptions v-if="project" :column="2" border size="small"> <div v-if="project" class="project-basic">
<el-divider content-position="left">基本信息</el-divider>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="项目名称" :span="2">{{ project.project_name }}</el-descriptions-item> <el-descriptions-item label="项目名称" :span="2">{{ project.project_name }}</el-descriptions-item>
<el-descriptions-item label="项目编号">{{ project.project_no || "-" }}</el-descriptions-item> <el-descriptions-item label="项目编号">{{ project.project_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ project.customer_name || "-" }}</el-descriptions-item> <el-descriptions-item label="项目类型">
<el-descriptions-item label="项目金额">{{ formatMoney(project.amount) }}</el-descriptions-item> <el-tag :type="isBackToBack ? 'warning' : 'info'" size="small" effect="plain">
{{ isBackToBack ? "上下游项目" : "单一项目" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="项目状态"> <el-descriptions-item label="项目状态">
<el-tag :type="projectStatusTag(project.status)" size="small"> <el-tag :type="projectStatusTag(project.status)" size="small">
{{ projectStatusText(project.status) }} {{ projectStatusText(project.status) }}
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="负责人">{{ project.owner_user_name || "-" }}</el-descriptions-item> <el-descriptions-item label="负责人">{{ project.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="客户行业">{{ project.industry || "-" }}</el-descriptions-item> <el-descriptions-item v-if="isBackToBack" label="项目毛利">
<el-descriptions-item label="开始日期">{{ formatDateOnly(project.start_date) }}</el-descriptions-item> <span class="profit">¥{{ formatMoney(profitAmount) }}</span>
<el-descriptions-item label="结束日期">{{ formatDateOnly(project.end_date) }}</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="项目起止时间" :span="2">
{{ formatDateOnly(contractStart) }} ~ {{ formatDateOnly(contractEnd) }}
</el-descriptions-item>
<el-descriptions-item label="来源商机"> <el-descriptions-item label="来源商机">
{{ project.business_name || (project.business_id ? `#${project.business_id}` : "-") }} {{ project.business_name || (project.business_id ? `#${project.business_id}` : "-") }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="对接人">{{ project.contact_person || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人职位">{{ project.contact_position || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人手机">{{ project.contact_phone || "-" }}</el-descriptions-item>
<el-descriptions-item label="项目地址" :span="2">{{ project.address || "-" }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(project.create_time) }}</el-descriptions-item> <el-descriptions-item label="创建时间">{{ formatDateTime(project.create_time) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(project.update_time) }}</el-descriptions-item> <el-descriptions-item label="更新时间">{{ formatDateTime(project.update_time) }}</el-descriptions-item>
<el-descriptions-item label="项目地址" :span="2">{{ project.address || "-" }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ project.remark || "-" }}</el-descriptions-item> <el-descriptions-item label="备注" :span="2">{{ project.remark || "-" }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<!-- 单一项目不存在上下游概念,只展示客户信息;上下游项目则区分上游(客户侧) -->
<el-divider content-position="left">{{ isBackToBack ? "上游信息" : "客户信息" }}</el-divider>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="客户名称">{{ project.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item :label="isBackToBack ? '上游金额' : '项目金额'">
{{ formatMoney(upstreamAmount) }}
</el-descriptions-item>
<el-descriptions-item :label="isBackToBack ? '上游对接人' : '对接人'">
{{ project.upstream_contact || project.contact_person || "-" }}
</el-descriptions-item>
<el-descriptions-item :label="isBackToBack ? '上游联系电话' : '联系电话'">
{{ project.upstream_phone || project.contact_phone || "-" }}
</el-descriptions-item>
</el-descriptions>
<!-- 下游(供应商侧):仅上下游项目展示 -->
<template v-if="isBackToBack">
<el-divider content-position="left">下游信息</el-divider>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="供应商/客户">
{{ project.supplier_name || "-" }}
<el-tag v-if="Number(project.supplier_ref_type) === 1" size="small" effect="plain" type="success">客户</el-tag>
<el-tag v-else-if="Number(project.supplier_ref_type) === 2" size="small" effect="plain" type="warning">
供应商
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="下游金额">{{ formatMoney(project.downstream_amount) }}</el-descriptions-item>
<el-descriptions-item label="下游对接人">{{ project.downstream_contact || "-" }}</el-descriptions-item>
<el-descriptions-item label="下游联系电话">{{ project.downstream_phone || "-" }}</el-descriptions-item>
</el-descriptions>
</template>
</div>
</template> </template>
<script setup> <script setup>
import { computed } from "vue";
import { import {
projectStatusText, projectStatusText,
projectStatusTag, projectStatusTag,
@@ -35,8 +74,46 @@ import {
formatDateOnly, formatDateOnly,
} from "../../dict"; } from "../../dict";
defineProps({ const props = defineProps({
// 项目详情对象 // 项目详情对象
project: { type: Object, default: null }, project: { type: Object, default: null },
}); });
/** 是否背靠背项目:需要展示下游(供应商)信息 */
const isBackToBack = computed(() => Number(props.project?.project_type) === 2);
/** 上游金额:新字段优先,兼容存量项目仅有旧「项目金额」的情况 */
const upstreamAmount = computed(
() => Number(props.project?.upstream_amount) || Number(props.project?.amount) || 0
);
/** 项目毛利:上游金额 − 下游金额 */
const profitAmount = computed(
() => upstreamAmount.value - (Number(props.project?.downstream_amount) || 0)
);
/** 项目起止时间:由关联合同汇总(最早开始 / 最晚结束),兼容历史手工日期 */
const contractStart = computed(() => props.project?.contract_start_date || props.project?.start_date || "");
const contractEnd = computed(() => props.project?.contract_end_date || props.project?.end_date || "");
</script> </script>
<style lang="less" scoped>
.project-basic {
padding-top:20px;
/* 首段标题紧贴容器顶部,避免多余留白 */
:deep(.el-divider:first-child) {
margin-top: 0;
}
}
.profit {
font-weight: 600;
color: var(--el-color-danger);
}
.tip {
margin-left: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -16,8 +16,8 @@
<template #label> <template #label>
<span class="tab-label"> <span class="tab-label">
<span class="tab-name">{{ c.contract_name }}</span> <span class="tab-name">{{ c.contract_name }}</span>
<el-tag :type="contractStatusTag(c.status)" size="small" effect="plain"> <el-tag :type="contractSideOf(c) === 'down' ? 'warning' : 'success'" size="small" effect="plain">
{{ contractStatusText(c.status) }} {{ contractSideOf(c) === "down" ? "下游" : "上游" }}
</el-tag> </el-tag>
</span> </span>
</template> </template>
@@ -25,12 +25,15 @@
<div class="contract-pane"> <div class="contract-pane">
<div class="pane-head"> <div class="pane-head">
<div class="pane-title"> <div class="pane-title">
<span class="name">{{ c.contract_name }}</span> <span
<!-- <el-tag :type="contractStatusTag(c.status)" size="small"> class="name link"
{{ contractStatusText(c.status) }} title="前往合同管理查看该合同完整详情(回款计划、电子合同、发票等)"
</el-tag> --> @click="goContract(c)"
<el-tag type="primary" size="small" effect="plain"> >
{{ ourRoleText(c.our_role) }} {{ c.contract_name }}
</span>
<el-tag :type="contractSideOf(c) === 'down' ? 'warning' : 'success'" size="small" effect="plain">
{{ contractSideOf(c) === "down" ? "下游" : "上游" }}
</el-tag> </el-tag>
</div> </div>
<!-- <div class="pane-actions"> <!-- <div class="pane-actions">
@@ -38,11 +41,10 @@
<el-button link type="danger" size="small" @click="handleDelete(c)">删除</el-button> <el-button link type="danger" size="small" @click="handleDelete(c)">删除</el-button>
</div> --> </div> -->
</div> </div>
<div class="pane-sub"> <el-divider content-position="left">基础信息</el-divider>
{{ c.contract_no || "-" }} · {{ contractCategoryText(c.contract_category) }}
</div>
<el-descriptions :column="2" border style="margin-top: 12px"> <el-descriptions :column="2" border style="margin-top: 12px">
<el-descriptions-item label="合同编号">{{ c.contract_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="合同类型">{{ contractCategoryText(c.contract_category) }}</el-descriptions-item>
<el-descriptions-item label="签订日期">{{ formatDateOnly(c.sign_date) }}</el-descriptions-item> <el-descriptions-item label="签订日期">{{ formatDateOnly(c.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ c.owner_user_name || "-" }}</el-descriptions-item> <el-descriptions-item label="负责人">{{ c.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(c.effective_date) }}</el-descriptions-item> <el-descriptions-item label="生效日期">{{ formatDateOnly(c.effective_date) }}</el-descriptions-item>
@@ -154,15 +156,14 @@ import { ref, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue"; import { Plus } from "@element-plus/icons-vue";
import { getContractList, deleteContract } from "@/api/crmContract"; import { getContractList, deleteContract } from "@/api/crmContract";
import { useRouter } from "vue-router";
import { useMenuStore } from "@/stores/menu";
import ContractCreate from "../../contract/components/create.vue"; import ContractCreate from "../../contract/components/create.vue";
import { PARTY_ROLES, buildSummary } from "../../contract/components/utils"; import { PARTY_ROLES, buildSummary } from "../../contract/components/utils";
import { import {
formatMoney, formatMoney,
formatDateOnly, formatDateOnly,
ourRoleText,
contractCategoryText, contractCategoryText,
contractStatusText,
contractStatusTag,
productCategoryText, productCategoryText,
productCategoryTag, productCategoryTag,
} from "../../dict"; } from "../../dict";
@@ -175,6 +176,9 @@ const props = defineProps({
}); });
const emit = defineEmits(["count"]); const emit = defineEmits(["count"]);
const router = useRouter();
const menuStore = useMenuStore();
const loading = ref(false); const loading = ref(false);
const list = ref([]); const list = ref([]);
const activeId = ref(""); const activeId = ref("");
@@ -190,6 +194,18 @@ const presetProject = computed(() =>
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-"; const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
/**
* 合同在项目中的上下游归属:
* 判定以「我方是收款方还是付款方」为准 —— 参与方中出现供应商(我方下游)即下游合同;
* 其余按我方角色判断:我方为甲方(采购 / 付款方)算下游,为乙方(供货 / 收款方)算上游。
* 注意:不能只看对方是否在客户库建档,客户也可能是我方的上游供应商。
*/
function contractSideOf(c) {
const parties = Array.isArray(c?.parties) ? c.parties : [];
if (parties.some((p) => Number(p?.ref_type) === 2)) return "down";
return Number(c?.our_role) === 1 ? "down" : "up";
}
/** 金额汇总:优先后端 summary,缺失时按产品清单前端重算 */ /** 金额汇总:优先后端 summary,缺失时按产品清单前端重算 */
function summaryOf(c) { function summaryOf(c) {
const s = c?.summary; const s = c?.summary;
@@ -220,6 +236,34 @@ async function reload() {
emit("count", list.value.length); emit("count", list.value.length);
} }
/**
* 查找「合同管理」菜单路径:按菜单中配置的组件路径定位,
* 避免硬编码(菜单路径可由管理员调整,且需当前用户有该菜单权限)。
*/
function findContractManagePath() {
const walk = (nodes) => {
for (const m of nodes || []) {
if (String(m.component_path || "").includes("crm/contract/index")) return m.path;
if (m.children?.length) {
const hit = walk(m.children);
if (hit) return hit;
}
}
return "";
};
return walk(menuStore.menus) || "";
}
/** 点击合同名称:跳转「合同管理」并自动打开该合同详情 */
function goContract(c) {
const path = findContractManagePath();
if (!path) {
ElMessage.warning("未找到「合同管理」菜单入口,请联系管理员配置");
return;
}
router.push({ path, query: { id: c.id } });
}
function handleCreate() { function handleCreate() {
editRow.value = null; editRow.value = null;
initStep.value = 1; initStep.value = 1;
@@ -312,18 +356,22 @@ defineExpose({ reload });
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
/* 可点击:跳转合同管理查看完整详情 */
&.link {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
} }
} }
.pane-actions { .pane-actions {
flex-shrink: 0; flex-shrink: 0;
} }
.pane-sub {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
} }
.summary-grid { .summary-grid {
@@ -8,6 +8,15 @@
@opened="handleOpened" @opened="handleOpened"
> >
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" label-position="right"> <el-form ref="formRef" :model="form" :rules="rules" label-width="110px" label-position="right">
<!-- 项目类型:决定是否需要维护下游(供应商)信息 -->
<el-form-item label="项目类型" prop="project_type">
<el-radio-group v-model="form.project_type">
<el-radio-button :value="1">单一项目</el-radio-button>
<el-radio-button :value="2">上下游项目</el-radio-button>
</el-radio-group>
</el-form-item>
<el-divider content-position="left">项目概况</el-divider>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="项目名称" prop="project_name"> <el-form-item label="项目名称" prop="project_name">
@@ -19,6 +28,40 @@
<el-input v-model="form.project_no" placeholder="请输入项目编号" maxlength="50" /> <el-input v-model="form.project_no" placeholder="请输入项目编号" maxlength="50" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12">
<el-form-item label="负责人" prop="owner_user_id">
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目状态" prop="status">
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="isBackToBack" :span="12">
<el-form-item label="项目毛利">
<span class="profit-value">¥{{ formatMoney(profitAmount) }}</span>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="项目地址" prop="address">
<el-input v-model="form.address" placeholder="请输入项目地址" maxlength="255" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
</el-form-item>
</el-col>
</el-row>
<!-- 单一项目不存在上下游概念,只展示客户信息;上下游项目才区分上游(客户侧) -->
<el-divider content-position="left">{{ isBackToBack ? "上游信息" : "客户信息" }}</el-divider>
<el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="客户" prop="customer_id"> <el-form-item label="客户" prop="customer_id">
<el-select <el-select
@@ -36,67 +79,103 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="客户行业" prop="industry"> <el-form-item :label="isBackToBack ? '上游金额' : '项目金额'" prop="upstream_amount">
<el-input v-model="form.industry" placeholder="如:互联网、制造业" maxlength="50" /> <el-input v-model="form.upstream_amount" placeholder="对客户的收入金额">
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目金额" prop="amount">
<el-input v-model="form.amount" placeholder="请输入项目金额">
<template #append>元</template> <template #append>元</template>
</el-input> </el-input>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="项目状态" prop="status"> <el-form-item :label="isBackToBack ? '上游对接人' : '对接人'" prop="upstream_contact">
<el-select v-model="form.status" placeholder="请选择" style="width: 100%"> <!-- 对接人从该客户已有联系人中带出(自动带出电话),不支持手工输入 -->
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" /> <el-select
v-model="form.upstream_contact"
filterable
clearable
:loading="upstreamContactLoading"
:disabled="!form.customer_id"
:placeholder="form.customer_id ? '选择客户联系人' : '请先选择客户'"
style="width: 100%"
@change="onUpstreamContactPick"
>
<el-option v-for="c in upstreamContacts" :key="c.id" :label="c.contact_name" :value="c.contact_name">
<span>{{ c.contact_name }}</span>
<span class="opt-meta">
{{ [c.position || c.department, c.mobile || c.phone].filter(Boolean).join(" · ") }}
</span>
</el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="负责人" prop="owner_user_id"> <el-form-item :label="isBackToBack ? '上游联系电话' : '联系电话'" prop="upstream_phone">
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%"> <el-input v-model="form.upstream_phone" placeholder="客户方对接人电话" maxlength="20" />
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开始日期" prop="start_date">
<el-date-picker v-model="form.start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束日期" prop="end_date">
<el-date-picker v-model="form.end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="对接人" prop="contact_person">
<el-input v-model="form.contact_person" placeholder="请输入对接人" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="对接人职位" prop="contact_position">
<el-input v-model="form.contact_position" placeholder="如:采购经理、技术总监" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="对接人手机" prop="contact_phone">
<el-input v-model="form.contact_phone" placeholder="请输入手机号" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="项目地址" prop="address">
<el-input v-model="form.address" placeholder="请输入项目地址" maxlength="255" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<!-- 下游信息:仅上下游项目需要维护 -->
<template v-if="isBackToBack">
<el-divider content-position="left">下游信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="供应商/客户" prop="supplier_name">
<el-autocomplete
v-model="form.supplier_name"
clearable
value-key="supplier_name"
:fetch-suggestions="querySuppliers"
placeholder="输入名称搜索供应商库 / 客户库,没有则直接填写"
style="width: 100%"
@focus="searchSuppliers(form.supplier_name || '')"
@select="applySupplier"
@blur="() => applySupplier(null)"
>
<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-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="下游金额" prop="downstream_amount">
<el-input v-model="form.downstream_amount" placeholder="支付给供应商的成本">
<template #append>元</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="下游对接人" prop="downstream_contact">
<!-- 对接人从下游主体(供应商 / 客户)已有联系人中带出,不支持手工输入 -->
<el-select
v-model="form.downstream_contact"
filterable
clearable
:loading="downstreamContactLoading"
:disabled="!form.supplier_id"
:placeholder="form.supplier_id ? '选择对方联系人' : '请先选择供应商/客户'"
style="width: 100%"
@change="onDownstreamContactPick"
>
<el-option v-for="c in downstreamContacts" :key="c.id" :label="c.contact_name" :value="c.contact_name">
<span>{{ c.contact_name }}</span>
<span class="opt-meta">
{{ [c.position || c.department, c.mobile || c.phone].filter(Boolean).join(" · ") }}
</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="下游联系电话" prop="downstream_phone">
<el-input v-model="form.downstream_phone" placeholder="供应商方对接人电话" maxlength="20" />
</el-form-item>
</el-col>
</el-row>
</template>
</el-form> </el-form>
<!-- <el-divider content-position="left"> <!-- <el-divider content-position="left">
@@ -113,14 +192,15 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, watch } from "vue"; import { computed, ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { createProject, updateProject } from "@/api/crmPipeline"; import { createProject, updateProject } from "@/api/crmPipeline";
import { getProductList } from "@/api/crmProduct"; import { getProductList } from "@/api/crmProduct";
import { getCrmCustomerList } from "@/api/crm"; import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
import { listContacts } from "@/api/contact";
import { getAllUsers } from "@/api/user"; import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { PROJECT_STATUS_OPTIONS } from "../../dict"; import { PROJECT_STATUS_OPTIONS, formatMoney } from "../../dict";
import ProductList from "../../contract/components/ProductList.vue"; import ProductList from "../../contract/components/ProductList.vue";
const props = defineProps({ const props = defineProps({
@@ -141,18 +221,26 @@ const customerLoading = ref(false);
const defaultForm = () => ({ const defaultForm = () => ({
project_name: "", project_name: "",
project_no: "", project_no: "",
// 项目类型:1=单一项目(仅上游) 2=上下游项目(上游客户 + 下游供应商)
project_type: 1,
customer_id: "", customer_id: "",
customer_name: "", customer_name: "",
supplier_id: 0,
supplier_ref_type: 0,
supplier_name: "",
supplier_industry: "",
owner_user_id: "", owner_user_id: "",
owner_user_name: "", owner_user_name: "",
industry: "", industry: "",
amount: "", upstream_amount: "",
downstream_amount: "",
upstream_contact: "",
upstream_phone: "",
downstream_contact: "",
downstream_phone: "",
status: 1, status: 1,
start_date: "", start_date: "",
end_date: "", end_date: "",
contact_person: "",
contact_position: "",
contact_phone: "",
address: "", address: "",
remark: "", remark: "",
products: [], products: [],
@@ -160,6 +248,29 @@ const defaultForm = () => ({
const form = reactive(defaultForm()); const form = reactive(defaultForm());
/** 是否上下游项目:需要维护下游(供应商)信息 */
const isBackToBack = computed(() => Number(form.project_type) === 2);
/** 项目毛利:上游金额 − 下游金额(仅上下游项目有意义) */
const profitAmount = computed(
() => (Number(form.upstream_amount) || 0) - (Number(form.downstream_amount) || 0)
);
/** 切换为单一项目时清空下游信息,避免残留数据造成误解 */
watch(
() => form.project_type,
(val) => {
if (Number(val) === 2) return;
form.supplier_id = 0;
form.supplier_ref_type = 0;
form.supplier_name = "";
form.supplier_industry = "";
form.downstream_amount = "";
form.downstream_contact = "";
form.downstream_phone = "";
}
);
/** 解析项目产品清单:兼容 JSON 字符串 / 数组 / 空,并补全行内 __key(el-table 的 row-key) */ /** 解析项目产品清单:兼容 JSON 字符串 / 数组 / 空,并补全行内 __key(el-table 的 row-key) */
function parseProducts(val) { function parseProducts(val) {
let arr = []; let arr = [];
@@ -220,6 +331,13 @@ watch(
internalId.value = props.editData.id; internalId.value = props.editData.id;
Object.assign(form, defaultForm(), props.editData); Object.assign(form, defaultForm(), props.editData);
form.status = Number(props.editData.status) || 1; form.status = Number(props.editData.status) || 1;
form.project_type = Number(props.editData.project_type) || 1;
// 存量项目兼容:只有旧「项目金额 / 对接人」时回填到上游侧
if (!Number(form.upstream_amount) && Number(props.editData.amount)) {
form.upstream_amount = props.editData.amount;
}
if (!form.upstream_contact) form.upstream_contact = props.editData.contact_person || "";
if (!form.upstream_phone) form.upstream_phone = props.editData.contact_phone || "";
// 解析项目已关联的产品清单(后端返回 JSON 字符串) // 解析项目已关联的产品清单(后端返回 JSON 字符串)
const rawProducts = props.editData.products; const rawProducts = props.editData.products;
form.products = parseProducts(rawProducts); form.products = parseProducts(rawProducts);
@@ -238,6 +356,9 @@ watch(
} }
loadUsers(); loadUsers();
searchCustomers(form.customer_name || ""); searchCustomers(form.customer_name || "");
// 对接人候选随所选企业加载(编辑回显时也需带上,赋值未变化时不触发 watch)
loadUpstreamContacts();
loadDownstreamContacts();
} }
} }
); );
@@ -267,6 +388,137 @@ async function searchCustomers(keyword) {
} }
} }
/* --------------------------- 下游主体(供应商 / 客户) --------------------------- */
const supplierOptions = ref([]);
/**
* 下游主体候选:同时检索供应商库与客户库。
* 同一家单位在系统里可能以客户身份建档,因此下游既支持选供应商,也支持选客户。
*/
async function searchSuppliers(keyword) {
const kw = String(keyword || "").trim();
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];
} catch {
supplierOptions.value = [];
}
}
/** 供应商名称输入建议:本地模糊过滤已加载候选(自由输入同样生效) */
function querySuppliers(query, cb) {
const kw = String(query || "").trim().toLowerCase();
const list = (supplierOptions.value || []).filter(
(s) => !kw || String(s.supplier_name || "").toLowerCase().includes(kw)
);
// 附加 value 字段:兼容 autocomplete 默认以 value 作为选中值
cb(list.slice(0, 50).map((s) => ({ ...s, value: s.supplier_name })));
}
/**
* 选中的库中主体:记录来源类型(1客户 / 2供应商)与 ID,并写入标准名称;
* 手工输入且未命中库时清空关联,仅按名称保存。
*/
function applySupplier(item) {
if (item && item.id) {
form.supplier_id = item.id;
form.supplier_ref_type = Number(item.ref_type) || 0;
form.supplier_name = item.supplier_name;
return;
}
const name = String(form.supplier_name || "").trim();
const hit = supplierOptions.value.find((s) => s.supplier_name === name);
if (hit) {
form.supplier_id = hit.id;
form.supplier_ref_type = Number(hit.ref_type) || 0;
} else {
form.supplier_id = 0;
form.supplier_ref_type = 0;
}
}
/* --------------------------- 对接人(取自企业联系人) --------------------------- */
const upstreamContacts = ref([]);
const upstreamContactLoading = ref(false);
const downstreamContacts = ref([]);
const downstreamContactLoading = ref(false);
/** 查询某企业下的联系人:relatedType 1=客户 2=供应商 */
async function fetchContactsOf(relatedType, relatedID) {
try {
const res = await listContacts({
related_type: relatedType,
related_id: relatedID,
page: 1,
pageSize: 100,
});
return res?.data?.list || [];
} catch {
return [];
}
}
/** 上游对接人候选:所选客户(上游)的联系人 */
async function loadUpstreamContacts() {
const id = Number(form.customer_id) || 0;
if (!id) {
upstreamContacts.value = [];
return;
}
upstreamContactLoading.value = true;
try {
upstreamContacts.value = await fetchContactsOf(1, id);
} finally {
upstreamContactLoading.value = false;
}
}
/** 下游对接人候选:所选下游主体(供应商 / 客户)的联系人 */
async function loadDownstreamContacts() {
const id = Number(form.supplier_id) || 0;
if (!id) {
downstreamContacts.value = [];
return;
}
downstreamContactLoading.value = true;
try {
downstreamContacts.value = await fetchContactsOf(Number(form.supplier_ref_type) === 1 ? 1 : 2, id);
} finally {
downstreamContactLoading.value = false;
}
}
/** 选择上游对接人:自动带出联系电话 */
function onUpstreamContactPick(name) {
const hit = upstreamContacts.value.find((c) => c.contact_name === name);
if (hit) form.upstream_phone = hit.mobile || hit.phone || "";
}
/** 选择下游对接人:自动带出联系电话 */
function onDownstreamContactPick(name) {
const hit = downstreamContacts.value.find((c) => c.contact_name === name);
if (hit) form.downstream_phone = hit.mobile || hit.phone || "";
}
// 客户 / 下游主体变化时刷新对接人候选(不清空已选值,兼容历史手工数据)
watch(() => form.customer_id, loadUpstreamContacts);
watch(() => [form.supplier_id, form.supplier_ref_type], loadDownstreamContacts);
function handleClose() { function handleClose() {
emit("update:visible", false); emit("update:visible", false);
} }
@@ -286,7 +538,35 @@ async function handleSubmit() {
if (owner) form.owner_user_name = owner.name; if (owner) form.owner_user_name = owner.name;
submitting.value = true; submitting.value = true;
try { try {
const payload = { ...form, amount: Number(form.amount) || 0, products: JSON.stringify(form.products || []) }; const upstream = Number(form.upstream_amount) || 0;
// 仅提交新字段(旧 amount / contact_* 不再使用,由后端按上游侧同步)
const payload = {
project_name: form.project_name,
project_no: form.project_no,
project_type: Number(form.project_type) || 1,
customer_id: Number(form.customer_id) || 0,
customer_name: form.customer_name,
supplier_id: Number(form.supplier_id) || 0,
supplier_ref_type: Number(form.supplier_ref_type) || 0,
supplier_name: form.supplier_name,
supplier_industry: form.supplier_industry,
owner_user_id: form.owner_user_id,
owner_user_name: form.owner_user_name,
industry: form.industry,
upstream_amount: upstream,
downstream_amount: isBackToBack.value ? Number(form.downstream_amount) || 0 : 0,
amount: upstream, // 兼容旧字段
status: Number(form.status) || 1,
start_date: form.start_date,
end_date: form.end_date,
upstream_contact: form.upstream_contact,
upstream_phone: form.upstream_phone,
downstream_contact: isBackToBack.value ? form.downstream_contact : "",
downstream_phone: isBackToBack.value ? form.downstream_phone : "",
address: form.address,
remark: form.remark,
products: JSON.stringify(form.products || []),
};
if (internalId.value) { if (internalId.value) {
await updateProject(internalId.value, payload); await updateProject(internalId.value, payload);
ElMessage.success("更新成功"); ElMessage.success("更新成功");
@@ -310,4 +590,21 @@ async function handleSubmit() {
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
margin-left: 8px; margin-left: 8px;
} }
.type-tip {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.profit-value {
font-weight: 600;
color: var(--el-color-danger);
}
.opt-meta {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style> </style>
+28 -8
View File
@@ -48,10 +48,30 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="project_no" label="项目编号" width="140" show-overflow-tooltip /> <el-table-column prop="project_no" label="项目编号" width="140" show-overflow-tooltip />
<el-table-column prop="customer_name" label="客户名称" min-width="160" show-overflow-tooltip /> <el-table-column label="类型" width="90" align="center">
<el-table-column label="项目金额" width="130" align="right"> <template #default="{ row }">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template> <el-tag size="small" effect="plain" :type="Number(row.project_type) === 2 ? 'warning' : 'info'">
{{ Number(row.project_type) === 2 ? "上下游" : "单一" }}
</el-tag>
</template>
</el-table-column> </el-table-column>
<el-table-column label="客户" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<!-- 上下游项目:客户 / 供应商;单一项目:仅客户 -->
<span v-if="Number(row.project_type) === 2">
{{ row.customer_name || "-" }} / {{ row.supplier_name || "-" }}
</span>
<span v-else>{{ row.customer_name || "-" }}</span>
</template>
</el-table-column>
<!-- <el-table-column label="上游金额" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.upstream_amount || row.amount) }}</template>
</el-table-column>
<el-table-column label="下游金额" width="120" align="right">
<template #default="{ row }">
{{ Number(row.project_type) === 2 ? formatMoney(row.downstream_amount) : "-" }}
</template>
</el-table-column> -->
<el-table-column label="逾期" width="100" align="center"> <el-table-column label="逾期" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="projectOverdue(row).overdue" type="danger" size="small" effect="dark"> <el-tag v-if="projectOverdue(row).overdue" type="danger" size="small" effect="dark">
@@ -61,17 +81,17 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="开始日期" width="120" align="center"> <el-table-column label="开始日期" width="120" align="center">
<template #default="{ row }">{{ formatDateOnly(row.start_date) }}</template> <template #default="{ row }">{{ formatDateOnly(row.contract_start_date || row.start_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="结束日期" width="120" align="center"> <el-table-column label="结束日期" width="120" align="center">
<template #default="{ row }">{{ formatDateOnly(row.end_date) }}</template> <template #default="{ row }">{{ formatDateOnly(row.contract_end_date || row.end_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="负责人" width="100"> <el-table-column label="负责人" width="100">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template> <template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column> </el-table-column>
<el-table-column label="对接人" width="100"> <!-- <el-table-column label="上游对接人" width="110">
<template #default="{ row }">{{ row.contact_person || "-" }}</template> <template #default="{ row }">{{ row.upstream_contact || row.contact_person || "-" }}</template>
</el-table-column> </el-table-column> -->
<template #empty><el-empty description="暂无项目" :image-size="80" /></template> <template #empty><el-empty description="暂无项目" :image-size="80" /></template>
</el-table> </el-table>
+29 -1
View File
@@ -64,6 +64,8 @@ func (c *BackendCrmBusinessController) List() {
tenantID := pipelineTenantID(claims) tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" { if keyword != "" {
kw := orm.NewCondition(). kw := orm.NewCondition().
Or("business_name__contains", keyword). Or("business_name__contains", keyword).
@@ -195,6 +197,11 @@ func (c *BackendCrmBusinessController) Create() {
CreateTime: now, CreateTime: now,
UpdateTime: now, UpdateTime: now,
} }
// 唯一性校验:同租户内不允许重复创建同名商机(重复数据请联系管理员转移跟进)
if crmNameDuplicated(new(models.TenantCrmBusiness), tenantID, "business_name", biz.BusinessName) {
pipelineErr(&c.Controller, 409, 409, "该商机名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
return
}
id, err := models.Orm.Insert(&biz) id, err := models.Orm.Insert(&biz)
if err != nil { if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
@@ -230,6 +237,11 @@ func (c *BackendCrmBusinessController) Update() {
pipelineErr(&c.Controller, 404, 404, "商机未找到") pipelineErr(&c.Controller, 404, 404, "商机未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
return
}
if biz.Locked == 1 { if biz.Locked == 1 {
pipelineErr(&c.Controller, 400, 400, "商机已转为项目并锁定,不能编辑") pipelineErr(&c.Controller, 400, 400, "商机已转为项目并锁定,不能编辑")
return return
@@ -306,6 +318,11 @@ func (c *BackendCrmBusinessController) Delete() {
pipelineErr(&c.Controller, 404, 404, "商机未找到") pipelineErr(&c.Controller, 404, 404, "商机未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
return
}
if biz.Locked == 1 || biz.Status == 2 { if biz.Locked == 1 || biz.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能删除") pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能删除")
return return
@@ -359,6 +376,11 @@ func (c *BackendCrmBusinessController) Convert() {
pipelineErr(&c.Controller, 404, 404, "商机未找到") pipelineErr(&c.Controller, 404, 404, "商机未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
return
}
if biz.Locked == 1 || biz.Status == 2 { if biz.Locked == 1 || biz.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "商机已转化,不能重复转化") pipelineErr(&c.Controller, 400, 400, "商机已转化,不能重复转化")
return return
@@ -378,16 +400,22 @@ func (c *BackendCrmBusinessController) Convert() {
TenantID: tenantID, TenantID: tenantID,
ProjectName: projectName, ProjectName: projectName,
ProjectNo: strings.TrimSpace(p.ProjectNo), ProjectNo: strings.TrimSpace(p.ProjectNo),
// 商机转化默认按「单一项目」创建:金额与对接人先落到上游侧,
// 后续可在项目中选择「上下游项目」并补充下游供应商 / 金额 / 对接人
ProjectType: 1,
BusinessID: &bizIDVal, BusinessID: &bizIDVal,
CustomerID: biz.CustomerID, CustomerID: biz.CustomerID,
CustomerName: biz.CustomerName, CustomerName: biz.CustomerName,
OwnerUserID: biz.OwnerUserID, OwnerUserID: biz.OwnerUserID,
OwnerUserName: biz.OwnerUserName, OwnerUserName: biz.OwnerUserName,
Industry: biz.Industry, Industry: biz.Industry,
Amount: amount, UpstreamAmount: amount,
Amount: amount, // 兼容旧字段
Status: 1, Status: 1,
StartDate: parsePipelineDate(p.StartDate), StartDate: parsePipelineDate(p.StartDate),
EndDate: parsePipelineDate(p.EndDate), EndDate: parsePipelineDate(p.EndDate),
UpstreamContact: biz.ContactPerson,
UpstreamPhone: biz.ContactPhone,
ContactPerson: biz.ContactPerson, ContactPerson: biz.ContactPerson,
ContactPosition: biz.ContactPosition, ContactPosition: biz.ContactPosition,
ContactPhone: biz.ContactPhone, ContactPhone: biz.ContactPhone,
+30
View File
@@ -26,6 +26,7 @@ type cluePayload struct {
OwnerUserID string `json:"owner_user_id"` OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"` OwnerUserName string `json:"owner_user_name"`
Industry string `json:"industry"` Industry string `json:"industry"`
Amount float64 `json:"amount"` // 预计金额
NextContactTime string `json:"next_contact_time"` NextContactTime string `json:"next_contact_time"`
ClueLevel string `json:"clue_level"` ClueLevel string `json:"clue_level"`
ContactPerson string `json:"contact_person"` ContactPerson string `json:"contact_person"`
@@ -61,6 +62,8 @@ func (c *BackendCrmClueController) List() {
tenantID := pipelineTenantID(claims) tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" { if keyword != "" {
kw := orm.NewCondition(). kw := orm.NewCondition().
Or("clue_name__contains", keyword). Or("clue_name__contains", keyword).
@@ -114,6 +117,11 @@ func (c *BackendCrmClueController) Detail() {
pipelineErr(&c.Controller, 404, 404, "线索未找到") pipelineErr(&c.Controller, 404, 404, "线索未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
return
}
pipelineOk(&c.Controller, clue) pipelineOk(&c.Controller, clue)
} }
@@ -161,6 +169,7 @@ func (c *BackendCrmClueController) Create() {
OwnerUserID: ownerID, OwnerUserID: ownerID,
OwnerUserName: ownerName, OwnerUserName: ownerName,
Industry: strings.TrimSpace(p.Industry), Industry: strings.TrimSpace(p.Industry),
Amount: p.Amount,
NextContactTime: parsePipelineDateTime(p.NextContactTime), NextContactTime: parsePipelineDateTime(p.NextContactTime),
ClueLevel: level, ClueLevel: level,
ContactPerson: strings.TrimSpace(p.ContactPerson), ContactPerson: strings.TrimSpace(p.ContactPerson),
@@ -176,6 +185,11 @@ func (c *BackendCrmClueController) Create() {
CreateTime: now, CreateTime: now,
UpdateTime: now, UpdateTime: now,
} }
// 唯一性校验:同租户内不允许重复创建同名线索(重复数据请联系管理员转移跟进)
if crmNameDuplicated(new(models.TenantCrmClue), clue.TenantID, "clue_name", clue.ClueName) {
pipelineErr(&c.Controller, 409, 409, "该线索名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
return
}
id, err := models.Orm.Insert(&clue) id, err := models.Orm.Insert(&clue)
if err != nil { if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
@@ -212,6 +226,11 @@ func (c *BackendCrmClueController) Update() {
pipelineErr(&c.Controller, 404, 404, "线索未找到") pipelineErr(&c.Controller, 404, 404, "线索未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
return
}
if clue.Locked == 1 { if clue.Locked == 1 {
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能编辑") pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能编辑")
return return
@@ -231,6 +250,7 @@ func (c *BackendCrmClueController) Update() {
clue.OwnerUserName = strings.TrimSpace(p.OwnerUserName) clue.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
} }
clue.Industry = strings.TrimSpace(p.Industry) clue.Industry = strings.TrimSpace(p.Industry)
clue.Amount = p.Amount
clue.NextContactTime = parsePipelineDateTime(p.NextContactTime) clue.NextContactTime = parsePipelineDateTime(p.NextContactTime)
if strings.TrimSpace(p.ClueLevel) != "" { if strings.TrimSpace(p.ClueLevel) != "" {
clue.ClueLevel = strings.TrimSpace(p.ClueLevel) clue.ClueLevel = strings.TrimSpace(p.ClueLevel)
@@ -272,6 +292,11 @@ func (c *BackendCrmClueController) Delete() {
pipelineErr(&c.Controller, 404, 404, "线索未找到") pipelineErr(&c.Controller, 404, 404, "线索未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
return
}
if clue.Locked == 1 || clue.Status == 2 { if clue.Locked == 1 || clue.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能删除") pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能删除")
return return
@@ -340,6 +365,11 @@ func (c *BackendCrmClueController) Convert() {
pipelineErr(&c.Controller, 404, 404, "线索未找到") pipelineErr(&c.Controller, 404, 404, "线索未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
return
}
if clue.Locked == 1 || clue.Status == 2 { if clue.Locked == 1 || clue.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "线索已转化,不能重复转化") pipelineErr(&c.Controller, 400, 400, "线索已转化,不能重复转化")
return return
+109 -22
View File
@@ -47,7 +47,6 @@ type contractPayload struct {
SignDate string `json:"sign_date"` SignDate string `json:"sign_date"`
EffectiveDate string `json:"effective_date"` EffectiveDate string `json:"effective_date"`
ExpireDate string `json:"expire_date"` ExpireDate string `json:"expire_date"`
TechDate string `json:"tech_date"`
Parties json.RawMessage `json:"parties"` Parties json.RawMessage `json:"parties"`
Products json.RawMessage `json:"products"` Products json.RawMessage `json:"products"`
Step int8 `json:"step"` Step int8 `json:"step"`
@@ -87,6 +86,8 @@ func (c *BackendCrmContractController) List() {
tenantID := pipelineTenantID(claims) tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" { if keyword != "" {
// 签约主体名称存储在 parties JSON 中,用 LIKE 一并匹配 // 签约主体名称存储在 parties JSON 中,用 LIKE 一并匹配
kw := orm.NewCondition(). kw := orm.NewCondition().
@@ -118,6 +119,20 @@ func (c *BackendCrmContractController) List() {
cond = cond.And("project_id", pid) cond = cond.And("project_id", pid)
} }
} }
// 周期筛选:与顶部统计同口径(合同有效期 [生效日期, 结束日期] 与所选周期重叠),
// period=month/quarter/year 取当期区间,period=custom 用 start_date / end_date
period := strings.TrimSpace(c.GetString("period"))
rangeStart := strings.TrimSpace(c.GetString("start_date"))
rangeEnd := strings.TrimSpace(c.GetString("end_date"))
if period != "" && period != "custom" {
rangeStart, rangeEnd = periodRange(period)
}
if rangeStart != "" {
cond = cond.AndCond(orm.NewCondition().Or("expire_date__isnull", true).Or("expire_date__gte", rangeStart))
}
if rangeEnd != "" {
cond = cond.AndCond(orm.NewCondition().Or("effective_date__isnull", true).Or("effective_date__lte", rangeEnd))
}
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond) qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
total, _ := qs.Count() total, _ := qs.Count()
@@ -127,7 +142,6 @@ func (c *BackendCrmContractController) List() {
} }
items := make([]contractResp, 0, len(list)) items := make([]contractResp, 0, len(list))
for i := range list { for i := range list {
c.syncContractExceptionStatus(&list[i])
items = append(items, buildContractResp(&list[i])) items = append(items, buildContractResp(&list[i]))
} }
pipelineOk(&c.Controller, map[string]interface{}{ pipelineOk(&c.Controller, map[string]interface{}{
@@ -185,7 +199,8 @@ func (c *BackendCrmContractController) Stats() {
}) })
} }
// periodRange 根据 period 返回 [start, end] 签约日期区间(YYYY-MM-DD):month/quarter/year 取当期。 // periodRange 根据 period 返回当期区间 [start, end](YYYY-MM-DD):month/quarter/year。
// 统计与列表均以该区间与合同有效期比较(有效期与周期重叠即命中)。
func periodRange(period string) (string, string) { func periodRange(period string) (string, string) {
now := time.Now() now := time.Now()
loc := now.Location() loc := now.Location()
@@ -226,7 +241,11 @@ func (c *BackendCrmContractController) Detail() {
pipelineErr(&c.Controller, 404, 404, "合同未找到") pipelineErr(&c.Controller, 404, 404, "合同未找到")
return return
} }
c.syncContractExceptionStatus(&row) // 数据范围校验:非管理员仅能查看本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权查看该合同数据")
return
}
pipelineOk(&c.Controller, buildContractResp(&row)) pipelineOk(&c.Controller, buildContractResp(&row))
} }
@@ -262,7 +281,6 @@ func (c *BackendCrmContractController) Create() {
SignDate: parsePipelineDate(p.SignDate), SignDate: parsePipelineDate(p.SignDate),
EffectiveDate: parsePipelineDate(p.EffectiveDate), EffectiveDate: parsePipelineDate(p.EffectiveDate),
ExpireDate: parsePipelineDate(p.ExpireDate), ExpireDate: parsePipelineDate(p.ExpireDate),
TechDate: parsePipelineDate(p.TechDate),
Status: pickInt8(p.Status, 1, 5), Status: pickInt8(p.Status, 1, 5),
Step: pickInt8(p.Step, 1, 2), Step: pickInt8(p.Step, 1, 2),
Remark: p.Remark, Remark: p.Remark,
@@ -310,6 +328,12 @@ func (c *BackendCrmContractController) Create() {
} }
applyContractAmounts(&row) applyContractAmounts(&row)
// 唯一性校验:同租户内「合同名称 + 签约方」完全一致才视为重复。
// 同名但签约方不同(例如我方分别作为甲方 / 乙方的两份合同)不算重复。
if crmContractDuplicated(tenantID, row.ContractName, row.Parties) {
pipelineErr(&c.Controller, 409, 409, "已存在同名且签约方相同的合同(可能由其他同事创建),如需继续跟进请联系管理员转移")
return
}
if _, err := models.Orm.Insert(&row); err != nil { if _, err := models.Orm.Insert(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return return
@@ -345,6 +369,11 @@ func (c *BackendCrmContractController) Update() {
pipelineErr(&c.Controller, 404, 404, "合同未找到") pipelineErr(&c.Controller, 404, 404, "合同未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
return
}
if strings.TrimSpace(p.ContractName) == "" { if strings.TrimSpace(p.ContractName) == "" {
pipelineErr(&c.Controller, 400, 400, "合同名称不能为空") pipelineErr(&c.Controller, 400, 400, "合同名称不能为空")
return return
@@ -385,7 +414,6 @@ func (c *BackendCrmContractController) Update() {
row.SignDate = parsePipelineDate(p.SignDate) row.SignDate = parsePipelineDate(p.SignDate)
row.EffectiveDate = parsePipelineDate(p.EffectiveDate) row.EffectiveDate = parsePipelineDate(p.EffectiveDate)
row.ExpireDate = parsePipelineDate(p.ExpireDate) row.ExpireDate = parsePipelineDate(p.ExpireDate)
row.TechDate = parsePipelineDate(p.TechDate)
if p.Parties != nil { if p.Parties != nil {
parties, err := normalizeContractJSON(p.Parties) parties, err := normalizeContractJSON(p.Parties)
if err != nil { if err != nil {
@@ -447,6 +475,11 @@ func (c *BackendCrmContractController) Delete() {
pipelineErr(&c.Controller, 404, 404, "合同未找到") pipelineErr(&c.Controller, 404, 404, "合同未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
return
}
if !canDeleteCrmRecord(claims, row.CreateUserID) { if !canDeleteCrmRecord(claims, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该合同") pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该合同")
return return
@@ -497,6 +530,11 @@ func (c *BackendCrmContractController) ChangeStatus() {
pipelineErr(&c.Controller, 404, 404, "合同未找到") pipelineErr(&c.Controller, 404, 404, "合同未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
return
}
if row.Status == p.Status { if row.Status == p.Status {
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": row.Status}) pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": row.Status})
return return
@@ -684,26 +722,75 @@ func contractReceivedAmount(tenantID string, contractID uint64) float64 {
return toFloat64(rows[0]["received"]) return toFloat64(rows[0]["received"])
} }
// syncContractExceptionStatus 履约中合同状态对账:技术日期已过但回款未回完(已收 < 合同总额, // crmContractDuplicated 判断同租户内是否已存在「合同名称 + 签约方」均一致的合同。
// 允许 0.005 浮点容差)时,自动流转为「执行异常」(5)。无技术日期或不满足翻转条件则不动。 //
// 在列表 / 详情读取时触发,也可在回款登记后调用,使状态及时反映。 // 判重口径:合同名称一致,且参与方(甲/乙/丙/丁)归一化签名一致。
func (c *BackendCrmContractController) syncContractExceptionStatus(row *models.TenantCrmContract) { // 因此同一名称下「我方作为甲方」与「我方作为乙方」的两份合同不算重复。
if row == nil || row.Status != 4 || row.TechDate == nil || row.ID == 0 { func crmContractDuplicated(tenantID, name, partiesJSON string) bool {
return name = strings.TrimSpace(name)
if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
return false
} }
now := time.Now() sig := contractPartiesSignature(partiesJSON)
if !row.TechDate.Before(now) { var rows []models.TenantCrmContract
return _, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).
Filter("contract_name", name).
All(&rows, "id", "parties")
if err != nil {
return false
} }
received := contractReceivedAmount(row.TenantID, row.ID) for i := range rows {
if row.TotalAmount > 0 && received+0.005 < row.TotalAmount { if contractPartiesSignature(rows[i].Parties) == sig {
row.Status = 5 return true
row.UpdateTime = now
_, _ = models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", row.ID).Filter("tenant_id", row.TenantID).
Update(orm.Params{"status": int8(5), "update_time": now})
} }
} }
return false
}
// contractPartiesSignature 参与方归一化签名:按甲/乙/丙/丁固定顺序拼接「角色:主体类型|主体」。
// 主体优先取关联主体 ID(ref_id),无 ID(手工录入名称)时退化为名称(ref_name);
// 参与方为空或解析失败时返回空串,此时判重退化为「仅合同名称一致」。
func contractPartiesSignature(partiesJSON string) string {
if strings.TrimSpace(partiesJSON) == "" {
return ""
}
var parties []map[string]interface{}
if err := json.Unmarshal([]byte(partiesJSON), &parties); err != nil {
return ""
}
str := func(v interface{}) string {
if v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
byRole := make(map[string]string, len(parties))
for _, p := range parties {
role := str(p["role"])
if role == "" {
continue
}
subject := str(p["ref_id"])
// ref_id 为 0 / 空表示未关联主体库(本公司或手工录入),此时用名称兜底,
// 避免不同主体都归到 "0" 造成误判为重复
if subject == "" || subject == "0" {
subject = str(p["ref_name"])
}
byRole[role] = str(p["ref_type"]) + "|" + subject
}
var sb strings.Builder
for _, role := range []string{"party_a", "party_b", "party_c", "party_d"} {
if v, ok := byRole[role]; ok {
sb.WriteString(role)
sb.WriteString(":")
sb.WriteString(v)
sb.WriteString(";")
}
}
return sb.String()
}
// genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。 // genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。
func genContractNo(tenantID string) string { func genContractNo(tenantID string) string {
+4 -1
View File
@@ -195,6 +195,7 @@ func crmSumEntities() map[string]dashEntity {
"projectAmount": {"yz_backend_crm_project", "create_time", ""}, "projectAmount": {"yz_backend_crm_project", "create_time", ""},
"contractAmount": {"yz_backend_crm_contract", "create_time", ""}, "contractAmount": {"yz_backend_crm_contract", "create_time", ""},
"chanceAmount": {"yz_backend_crm_business", "create_time", ""}, "chanceAmount": {"yz_backend_crm_business", "create_time", ""},
"clueAmount": {"yz_backend_crm_clue", "create_time", ""},
"paymentAmount": {"yz_backend_crm_payback", "create_time", ""}, "paymentAmount": {"yz_backend_crm_payback", "create_time", ""},
} }
} }
@@ -207,6 +208,8 @@ func crmAmountCol(key string) string {
return "total_amount" return "total_amount"
case "chanceAmount": case "chanceAmount":
return "amount" return "amount"
case "clueAmount":
return "amount"
case "paymentAmount": case "paymentAmount":
return "received_amount" return "received_amount"
} }
@@ -240,7 +243,7 @@ func (c *BackendCrmDashboardController) Summary() {
sums := crmSumEntities() sums := crmSumEntities()
statKeys := []string{ statKeys := []string{
"newCustomer", "newContact", "newProject", "newContract", "newClue", "newCustomer", "newContact", "newProject", "newContract", "newClue",
"newChance", "projectAmount", "contractAmount", "chanceAmount", "paymentAmount", "newVisit", "newChance", "clueAmount", "chanceAmount", "projectAmount", "paymentAmount", "newVisit",
} }
stats := map[string]map[string]float64{} stats := map[string]map[string]float64{}
+67 -4
View File
@@ -60,10 +60,12 @@ type paybackPayload struct {
ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填) ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填)
} }
// paybackResp 列表 / 详情响应:items 解析为 JSON 数组透出。 // paybackResp 列表 / 详情响应:items 解析为 JSON 数组透出;
// children 为回款明细(树形子集:该计划下一笔笔实际回款记录)。
type paybackResp struct { type paybackResp struct {
models.TenantCrmPayback models.TenantCrmPayback
Items json.RawMessage `json:"items"` Items json.RawMessage `json:"items"`
Children []models.TenantCrmPaybackRecord `json:"children"`
} }
// paybackProgress 回款进度行(跨计划摊平的计划明细)。 // paybackProgress 回款进度行(跨计划摊平的计划明细)。
@@ -109,6 +111,8 @@ func (c *BackendCrmPaybackController) List() {
tenantID := pipelineTenantID(claims) tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" { if keyword != "" {
cond = cond.AndCond(orm.NewCondition(). cond = cond.AndCond(orm.NewCondition().
Or("contract_name__contains", keyword). Or("contract_name__contains", keyword).
@@ -138,9 +142,32 @@ func (c *BackendCrmPaybackController) List() {
// 排序:进行中(1) 在前、已完成(2) 在后,同状态内按最新创建优先 // 排序:进行中(1) 在前、已完成(2) 在后,同状态内按最新创建优先
_, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list) _, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
} }
// 组装树形数据:每个回款计划挂上它的回款记录作为明细子集
planIDs := make([]uint64, 0, len(list))
for i := range list {
planIDs = append(planIDs, list[i].ID)
}
recordsByPlan := make(map[uint64][]models.TenantCrmPaybackRecord, len(list))
if len(planIDs) > 0 {
var recs []models.TenantCrmPaybackRecord
_, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
Filter("tenant_id", tenantID).
Filter("payback_id__in", planIDs).
Filter("delete_time__isnull", true).
OrderBy("-received_date", "-id").All(&recs)
for _, r := range recs {
recordsByPlan[r.PaybackID] = append(recordsByPlan[r.PaybackID], r)
}
}
items := make([]paybackResp, 0, len(list)) items := make([]paybackResp, 0, len(list))
for i := range list { for i := range list {
items = append(items, buildPaybackResp(&list[i])) resp := buildPaybackResp(&list[i])
if recs := recordsByPlan[list[i].ID]; len(recs) > 0 {
resp.Children = recs
} else {
resp.Children = []models.TenantCrmPaybackRecord{}
}
items = append(items, resp)
} }
// 汇总(当前筛选条件下全部计划的计划总额 / 已回款 / 待回款),用于列表页统计卡片 // 汇总(当前筛选条件下全部计划的计划总额 / 已回款 / 待回款),用于列表页统计卡片
@@ -271,6 +298,11 @@ func (c *BackendCrmPaybackController) Update() {
pipelineErr(&c.Controller, 404, 404, "回款计划未找到") pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
return
}
if p.ContractID > 0 { if p.ContractID > 0 {
row.ContractID = &p.ContractID row.ContractID = &p.ContractID
@@ -302,20 +334,23 @@ func (c *BackendCrmPaybackController) Update() {
row.Status = pickInt8(p.Status, 1, 2) row.Status = pickInt8(p.Status, 1, 2)
} }
if p.Items != nil { if p.Items != nil {
itemsJSON, _, err := normalizePaybackItems(p.Items) itemsJSON, total, err := normalizePaybackItems(p.Items)
if err != nil { if err != nil {
pipelineErr(&c.Controller, 400, 400, "计划明细格式错误") pipelineErr(&c.Controller, 400, 400, "计划明细格式错误")
return return
} }
row.Items = itemsJSON row.Items = itemsJSON
row.TotalAmount = total // 计划总额随明细重算
} }
applyPaybackReceived(&row)
row.UpdateTime = time.Now() row.UpdateTime = time.Now()
if _, err := models.Orm.Update(&row); err != nil { if _, err := models.Orm.Update(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error()) pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return return
} }
// 已回款金额一律以「回款记录」为准重新汇总:
// 明细里不承载实收数据,若此处按明细重算,编辑计划(如调整回款日期)会把已回款冲成 0。
refreshPlanReceived(tenantID, row.ID)
crmWriteLog(tenantID, 4, row.ID, "update", "更新回款计划:"+row.ContractName, claims) crmWriteLog(tenantID, 4, row.ID, "update", "更新回款计划:"+row.ContractName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID}) pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
} }
@@ -340,10 +375,20 @@ func (c *BackendCrmPaybackController) Delete() {
pipelineErr(&c.Controller, 404, 404, "回款计划未找到") pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
return
}
if !canDeleteCrmRecord(claims, row.CreateUserID) { if !canDeleteCrmRecord(claims, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该回款计划") pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该回款计划")
return return
} }
// 已完成(回款已收齐)的回款计划不允许删除
if row.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不允许删除")
return
}
now := time.Now() now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmPayback)). _, err = models.Orm.QueryTable(new(models.TenantCrmPayback)).
Filter("id", id).Filter("tenant_id", tenantID). Filter("id", id).Filter("tenant_id", tenantID).
@@ -369,6 +414,8 @@ func (c *BackendCrmPaybackController) ProgressList() {
contractID := strings.TrimSpace(c.GetString("contract_id")) contractID := strings.TrimSpace(c.GetString("contract_id"))
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" { if keyword != "" {
cond = cond.AndCond(orm.NewCondition(). cond = cond.AndCond(orm.NewCondition().
Or("contract_name__contains", keyword). Or("contract_name__contains", keyword).
@@ -456,6 +503,11 @@ func (c *BackendCrmPaybackController) Receive() {
pipelineErr(&c.Controller, 404, 404, "回款计划未找到") pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
return
}
items := parsePaybackItems(row.Items) items := parsePaybackItems(row.Items)
matched := false matched := false
for i := range items { for i := range items {
@@ -664,6 +716,8 @@ func (c *BackendCrmPaybackController) RecordList() {
paybackID, _ := c.GetUint64("payback_id") paybackID, _ := c.GetUint64("payback_id")
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if contractID > 0 { if contractID > 0 {
cond = cond.And("contract_id", contractID) cond = cond.And("contract_id", contractID)
} }
@@ -678,6 +732,7 @@ func (c *BackendCrmPaybackController) RecordList() {
// (已回款由回款记录汇总回写,兼容历史计划) // (已回款由回款记录汇总回写,兼容历史计划)
var totalPlanned, totalReceived float64 var totalPlanned, totalReceived float64
planCond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) planCond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
planCond = crmApplyOwnerScope(planCond, claims)
if contractID > 0 { if contractID > 0 {
planCond = planCond.And("contract_id", contractID) planCond = planCond.And("contract_id", contractID)
} }
@@ -792,6 +847,14 @@ func (c *BackendCrmPaybackController) DeleteRecord() {
pipelineErr(&c.Controller, 404, 404, "回款记录不存在") pipelineErr(&c.Controller, 404, 404, "回款记录不存在")
return return
} }
// 所属回款计划已完成(回款已收齐)时不允许删除回款记录,避免破坏已完成状态
var plan models.TenantCrmPayback
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
Filter("id", row.PaybackID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&plan); err == nil && plan.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "所属回款计划已完成(回款已收齐),不允许删除回款记录")
return
}
now := time.Now() now := time.Now()
if _, err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)). if _, err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
Filter("id", id).Filter("tenant_id", tenantID). Filter("id", id).Filter("tenant_id", tenantID).
+66
View File
@@ -6,6 +6,8 @@ import (
"server/models" "server/models"
"server/pkg/jwtutil" "server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
) )
// CRM 客户 / 供应商的删除权限规则: // CRM 客户 / 供应商的删除权限规则:
@@ -70,3 +72,67 @@ func isTenantAdmin(userID int, tenantID int) bool {
} }
return rights == "" || rights == "[]" || rights == "null" return rights == "" || rights == "[]" || rights == "null"
} }
// ========================== CRM 数据范围(用户级隔离) ==========================
//
// 数据可见范围约定(仅作用于用户级业务数据):
// - 平台管理员 / 租户管理员(含未分配角色、角色未配置权限等全权限情形)→ 可见租户内全部数据;
// - 其他用户 → 仅可见「负责人是自己」或「由自己创建」的数据。
//
// 租户共享数据不在此列,始终对全租户可见:供应商、客户公海、产品列表、产品分类。
// 后续如需按角色配置数据范围,只需扩展 crmScopeSelfOnly 的判定。
// crmScopeSelfOnly 判断当前用户是否只能看「本人」数据(true=仅本人)。
func crmScopeSelfOnly(claims *jwtutil.Claims) bool {
if claims == nil {
return true
}
if claims.UserType == "platform" {
return false
}
return !isTenantAdmin(claims.UserID, claims.TenantId)
}
// crmApplyOwnerScope 为列表查询条件追加数据范围限制(需要「仅本人」时)。
// 需要用户级隔离的模块请在构造完 tenant 条件后调用。
func crmApplyOwnerScope(cond *orm.Condition, claims *jwtutil.Claims) *orm.Condition {
if !crmScopeSelfOnly(claims) {
return cond
}
uid := pipelineUID(claims)
return cond.AndCond(orm.NewCondition().
Or("owner_user_id", uid).
Or("create_user_id", uid))
}
// canAccessCrmRecord 判断当前用户是否有权访问(查看 / 编辑 / 删除)指定记录。
// ownerUserID / createUserID 为记录的负责人与创建人(历史数据可能为空)。
func canAccessCrmRecord(claims *jwtutil.Claims, ownerUserID, createUserID string) bool {
if claims == nil {
return false
}
// 平台 / 租户管理员可访问全部
if !crmScopeSelfOnly(claims) {
return true
}
uid := pipelineUID(claims)
if ownerUserID != "" && ownerUserID == uid {
return true
}
return createUserID != "" && createUserID == uid
}
// crmNameDuplicated 判断同租户内是否已存在同名记录(不含软删除),用于创建时的唯一性校验。
// model 传 new(models.XXX);nameField 为数据库列名(如 clue_name)。
func crmNameDuplicated(model interface{}, tenantID, nameField, name string) bool {
name = strings.TrimSpace(name)
if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
return false
}
cnt, err := models.Orm.QueryTable(model).
Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).
Filter(nameField, name).
Count()
return err == nil && cnt > 0
}
+18 -4
View File
@@ -146,6 +146,13 @@ func (c *BackendCrmProductController) Create() {
if lineType != "dev" { if lineType != "dev" {
lineType = "product" lineType = "product"
} }
// 非开发行不保留开发字段,避免残留(如 dev_tree="[]")导致清单调用时被误判为软件开发行
devUnitPrice := p.DevUnitPrice
devTree := string(p.DevTree)
if lineType != "dev" {
devUnitPrice = 0
devTree = ""
}
now := time.Now() now := time.Now()
row := models.TenantCrmProduct{ row := models.TenantCrmProduct{
TenantID: pipelineTenantID(claims), TenantID: pipelineTenantID(claims),
@@ -158,8 +165,8 @@ func (c *BackendCrmProductController) Create() {
CostPrice: p.CostPrice, CostPrice: p.CostPrice,
TaxRate: p.TaxRate, TaxRate: p.TaxRate,
LineType: lineType, LineType: lineType,
DevUnitPrice: p.DevUnitPrice, DevUnitPrice: devUnitPrice,
DevTree: string(p.DevTree), DevTree: devTree,
Status: status, Status: status,
Remark: p.Remark, Remark: p.Remark,
CreateUserID: pipelineUID(claims), CreateUserID: pipelineUID(claims),
@@ -212,6 +219,13 @@ func (c *BackendCrmProductController) Update() {
if lineType != "dev" { if lineType != "dev" {
lineType = "product" lineType = "product"
} }
// 非开发行清空开发字段,避免残留导致清单调用时被误判为软件开发行
devUnitPrice := p.DevUnitPrice
devTree := string(p.DevTree)
if lineType != "dev" {
devUnitPrice = 0
devTree = ""
}
now := time.Now() now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProduct)). _, err = models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID). Filter("id", id).Filter("tenant_id", tenantID).
@@ -225,8 +239,8 @@ func (c *BackendCrmProductController) Update() {
"cost_price": p.CostPrice, "cost_price": p.CostPrice,
"tax_rate": p.TaxRate, "tax_rate": p.TaxRate,
"line_type": lineType, "line_type": lineType,
"dev_unit_price": p.DevUnitPrice, "dev_unit_price": devUnitPrice,
"dev_tree": p.DevTree, "dev_tree": devTree,
"status": status, "status": status,
"remark": p.Remark, "remark": p.Remark,
"update_time": now, "update_time": now,
+182 -9
View File
@@ -24,15 +24,30 @@ type BackendCrmProjectController struct {
type projectPayload struct { type projectPayload struct {
ProjectName string `json:"project_name"` ProjectName string `json:"project_name"`
ProjectNo string `json:"project_no"` ProjectNo string `json:"project_no"`
// 项目类型:1=单一项目(仅上游客户) 2=上下游项目(上游客户 + 下游供应商)
ProjectType int8 `json:"project_type"`
CustomerID uint64 `json:"customer_id"` CustomerID uint64 `json:"customer_id"`
CustomerName string `json:"customer_name"` CustomerName string `json:"customer_name"`
SupplierID uint64 `json:"supplier_id"`
SupplierRefType int8 `json:"supplier_ref_type"` // 下游主体来源:1客户 2供应商
SupplierName string `json:"supplier_name"`
SupplierIndustry string `json:"supplier_industry"`
OwnerUserID string `json:"owner_user_id"` OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"` OwnerUserName string `json:"owner_user_name"`
Industry string `json:"industry"` Industry string `json:"industry"`
// 金额:上游=对客户收入,下游=对供应商成本
UpstreamAmount float64 `json:"upstream_amount"`
DownstreamAmount float64 `json:"downstream_amount"`
// Amount 为旧「项目金额」,未传上游金额时回退使用(兼容未升级的调用方)
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
Status int8 `json:"status"` Status int8 `json:"status"`
StartDate string `json:"start_date"` 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"`
DownstreamContact string `json:"downstream_contact"`
DownstreamPhone string `json:"downstream_phone"`
ContactPerson string `json:"contact_person"` ContactPerson string `json:"contact_person"`
ContactPosition string `json:"contact_position"` ContactPosition string `json:"contact_position"`
ContactPhone string `json:"contact_phone"` ContactPhone string `json:"contact_phone"`
@@ -41,6 +56,109 @@ type projectPayload struct {
Products string `json:"products"` // 项目产品清单JSON(生成时写入产品管理) Products string `json:"products"` // 项目产品清单JSON(生成时写入产品管理)
} }
// normalizeProjectFields 归一项目类型、上下游金额与上下游对接人:
// - 项目类型仅识别 2=上下游,其余一律按 1=单一项目处理;
// - 上游金额 / 上游对接人缺省时回退旧的 amount / contact_* 字段(兼容未升级的调用方);
// - 单一项目不存在下游,下游金额与下游对接人一并清空。
func normalizeProjectFields(p *projectPayload) (pt int8, upstream, downstream float64, upContact, upPhone, downContact, downPhone string) {
pt = p.ProjectType
if pt != 2 {
pt = 1
}
upstream = p.UpstreamAmount
if upstream == 0 && p.Amount != 0 {
upstream = p.Amount
}
upContact = strings.TrimSpace(firstNonEmpty(p.UpstreamContact, p.ContactPerson))
upPhone = strings.TrimSpace(firstNonEmpty(p.UpstreamPhone, p.ContactPhone))
if pt == 2 {
downstream = p.DownstreamAmount
downContact = strings.TrimSpace(p.DownstreamContact)
downPhone = strings.TrimSpace(p.DownstreamPhone)
}
return
}
// resolveProjectSupplier 解析项目下游主体:
// refType=2 关联供应商库,refType=1 关联客户库(同一家单位可能以客户身份建档);
// 命中时以库中的 ID 与标准名称为准,未命中(或未选择库记录)时保留手工填写的名称、不保留关联。
func resolveProjectSupplier(tenantID string, refType int8, refID uint64, name string) (*uint64, int8, string) {
name = strings.TrimSpace(name)
if refID == 0 {
return nil, 0, name
}
switch refType {
case 2:
var sup models.TenantCrmSupplier
if err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
Filter("id", refID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&sup); err != nil {
return nil, 0, name
}
id := sup.ID
if strings.TrimSpace(sup.SupplierName) != "" {
name = sup.SupplierName
}
return &id, 2, name
case 1:
var cus models.TenantCrmCustomer
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
Filter("id", refID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&cus); err != nil {
return nil, 0, name
}
id := cus.ID
if strings.TrimSpace(cus.CustomerName) != "" {
name = cus.CustomerName
}
return &id, 1, name
}
return nil, 0, name
}
// attachProjectContractPeriod 汇总项目下所有合同的实际起止时间:
// 开始取各合同最早的生效日期(未填生效日期时回退签订日期),结束取最晚的结束日期。
// 项目不再手工维护起止时间,列表 / 详情统一按此展示;改合同后自动跟随,无需同步。
func attachProjectContractPeriod(tenantID string, list []models.TenantCrmProject) {
if len(list) == 0 {
return
}
idx := make(map[uint64]int, len(list))
ids := make([]uint64, 0, len(list))
for i := range list {
idx[list[i].ID] = i
ids = append(ids, list[i].ID)
}
var rows []models.TenantCrmContract
_, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("tenant_id", tenantID).Filter("delete_time__isnull", true).
Filter("project_id__in", ids).
All(&rows, "project_id", "sign_date", "effective_date", "expire_date")
if err != nil {
return
}
for i := range rows {
ct := rows[i]
if ct.ProjectID == nil {
continue
}
pos, ok := idx[*ct.ProjectID]
if !ok {
continue
}
start := ct.EffectiveDate
if start == nil {
start = ct.SignDate
}
if start != nil && (list[pos].ContractStartDate == nil || start.Before(*list[pos].ContractStartDate)) {
list[pos].ContractStartDate = start
}
if ct.ExpireDate != nil && (list[pos].ContractEndDate == nil || ct.ExpireDate.After(*list[pos].ContractEndDate)) {
list[pos].ContractEndDate = ct.ExpireDate
}
}
}
// List GET /backend/crm/project/list // List GET /backend/crm/project/list
func (c *BackendCrmProjectController) List() { func (c *BackendCrmProjectController) List() {
claims, err := pipelineClaims(&c.Controller) claims, err := pipelineClaims(&c.Controller)
@@ -62,11 +180,14 @@ func (c *BackendCrmProjectController) List() {
tenantID := pipelineTenantID(claims) tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" { if keyword != "" {
kw := orm.NewCondition(). kw := orm.NewCondition().
Or("project_name__contains", keyword). Or("project_name__contains", keyword).
Or("project_no__contains", keyword). Or("project_no__contains", keyword).
Or("customer_name__contains", keyword) Or("customer_name__contains", keyword).
Or("supplier_name__contains", keyword)
cond = cond.AndCond(kw) cond = cond.AndCond(kw)
} }
if status != "" { if status != "" {
@@ -83,6 +204,8 @@ func (c *BackendCrmProjectController) List() {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list) _, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
} }
attachBusinessName(tenantID, list) attachBusinessName(tenantID, list)
// 项目起止时间由关联合同汇总(不再依赖手工维护的开始 / 结束日期)
attachProjectContractPeriod(tenantID, list)
pipelineOk(&c.Controller, map[string]interface{}{ pipelineOk(&c.Controller, map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize, "list": list, "total": total, "page": page, "pageSize": pageSize,
}) })
@@ -110,7 +233,10 @@ func (c *BackendCrmProjectController) Detail() {
} }
tmp := []models.TenantCrmProject{proj} tmp := []models.TenantCrmProject{proj}
attachBusinessName(pipelineTenantID(claims), tmp) attachBusinessName(pipelineTenantID(claims), tmp)
attachProjectContractPeriod(pipelineTenantID(claims), tmp)
proj.BusinessName = tmp[0].BusinessName proj.BusinessName = tmp[0].BusinessName
proj.ContractStartDate = tmp[0].ContractStartDate
proj.ContractEndDate = tmp[0].ContractEndDate
// 存量项目兜底:确保文档库「共享文档 / 项目文档」下已建立同名文件夹(失败静默,不影响详情) // 存量项目兜底:确保文档库「共享文档 / 项目文档」下已建立同名文件夹(失败静默,不影响详情)
if proj.DocCategoryID == 0 { if proj.DocCategoryID == 0 {
if cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, proj.ID, proj.ProjectName, 0); err == nil && cid > 0 { if cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, proj.ID, proj.ProjectName, 0); err == nil && cid > 0 {
@@ -155,23 +281,39 @@ func (c *BackendCrmProjectController) Create() {
if status == 0 { if status == 0 {
status = 1 status = 1
} }
// 项目类型 / 上下游金额 / 上下游对接人归一(单一项目自动清空下游信息)
pt, upstream, downstream, upContact, upPhone, downContact, downPhone := normalizeProjectFields(&p)
supplierID, supplierRefType, supplierName := resolveProjectSupplier(tenantID, p.SupplierRefType, p.SupplierID, p.SupplierName)
if pt == 1 {
supplierID, supplierRefType, supplierName = nil, 0, ""
}
now := time.Now() now := time.Now()
proj := models.TenantCrmProject{ proj := models.TenantCrmProject{
TenantID: tenantID, TenantID: tenantID,
ProjectName: strings.TrimSpace(p.ProjectName), ProjectName: strings.TrimSpace(p.ProjectName),
ProjectNo: strings.TrimSpace(p.ProjectNo), ProjectNo: strings.TrimSpace(p.ProjectNo),
ProjectType: pt,
CustomerID: custIDVal, CustomerID: custIDVal,
CustomerName: customerName, CustomerName: customerName,
SupplierID: supplierID,
SupplierRefType: supplierRefType,
SupplierName: supplierName,
SupplierIndustry: strings.TrimSpace(p.SupplierIndustry),
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)), OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)), OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
Industry: strings.TrimSpace(p.Industry), Industry: strings.TrimSpace(p.Industry),
Amount: p.Amount, UpstreamAmount: upstream,
DownstreamAmount: downstream,
Amount: upstream, // 兼容旧字段:始终等于上游金额
Status: status, Status: status,
StartDate: parsePipelineDate(p.StartDate), StartDate: parsePipelineDate(p.StartDate),
EndDate: parsePipelineDate(p.EndDate), EndDate: parsePipelineDate(p.EndDate),
ContactPerson: strings.TrimSpace(p.ContactPerson), UpstreamContact: upContact,
ContactPosition: strings.TrimSpace(p.ContactPosition), UpstreamPhone: upPhone,
ContactPhone: strings.TrimSpace(p.ContactPhone), DownstreamContact: downContact,
DownstreamPhone: downPhone,
ContactPerson: upContact, // 旧字段同步为上游对接人,便于历史查询
ContactPhone: upPhone,
Address: strings.TrimSpace(p.Address), Address: strings.TrimSpace(p.Address),
Remark: p.Remark, Remark: p.Remark,
Products: p.Products, Products: p.Products,
@@ -179,6 +321,11 @@ func (c *BackendCrmProjectController) Create() {
CreateTime: now, CreateTime: now,
UpdateTime: now, UpdateTime: now,
} }
// 唯一性校验:同租户内不允许重复创建同名项目(重复数据请联系管理员转移跟进)
if crmNameDuplicated(new(models.TenantCrmProject), tenantID, "project_name", proj.ProjectName) {
pipelineErr(&c.Controller, 409, 409, "该项目名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
return
}
id, err := models.Orm.Insert(&proj) id, err := models.Orm.Insert(&proj)
if err != nil { if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
@@ -228,6 +375,11 @@ func (c *BackendCrmProjectController) Update() {
pipelineErr(&c.Controller, 404, 404, "项目未找到") pipelineErr(&c.Controller, 404, 404, "项目未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据")
return
}
if strings.TrimSpace(p.ProjectName) == "" { if strings.TrimSpace(p.ProjectName) == "" {
pipelineErr(&c.Controller, 400, 400, "项目名称不能为空") pipelineErr(&c.Controller, 400, 400, "项目名称不能为空")
return return
@@ -254,15 +406,31 @@ func (c *BackendCrmProjectController) Update() {
proj.OwnerUserName = strings.TrimSpace(p.OwnerUserName) proj.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
} }
proj.Industry = strings.TrimSpace(p.Industry) proj.Industry = strings.TrimSpace(p.Industry)
proj.Amount = p.Amount // 项目类型 / 上下游金额 / 上下游对接人(单一项目自动清空下游信息)
pt, upstream, downstream, upContact, upPhone, downContact, downPhone := normalizeProjectFields(&p)
supplierID, supplierRefType, supplierName := resolveProjectSupplier(tenantID, p.SupplierRefType, p.SupplierID, p.SupplierName)
if pt == 1 {
supplierID, supplierRefType, supplierName = nil, 0, ""
}
proj.ProjectType = pt
proj.SupplierID = supplierID
proj.SupplierRefType = supplierRefType
proj.SupplierName = supplierName
proj.SupplierIndustry = strings.TrimSpace(p.SupplierIndustry)
proj.UpstreamAmount = upstream
proj.DownstreamAmount = downstream
proj.Amount = upstream // 兼容旧字段:始终等于上游金额
proj.UpstreamContact = upContact
proj.UpstreamPhone = upPhone
proj.DownstreamContact = downContact
proj.DownstreamPhone = downPhone
proj.ContactPerson = upContact
proj.ContactPhone = upPhone
if p.Status != 0 { if p.Status != 0 {
proj.Status = p.Status proj.Status = p.Status
} }
proj.StartDate = parsePipelineDate(p.StartDate) proj.StartDate = parsePipelineDate(p.StartDate)
proj.EndDate = parsePipelineDate(p.EndDate) proj.EndDate = parsePipelineDate(p.EndDate)
proj.ContactPerson = strings.TrimSpace(p.ContactPerson)
proj.ContactPosition = strings.TrimSpace(p.ContactPosition)
proj.ContactPhone = strings.TrimSpace(p.ContactPhone)
proj.Address = strings.TrimSpace(p.Address) proj.Address = strings.TrimSpace(p.Address)
proj.Remark = p.Remark proj.Remark = p.Remark
proj.Products = p.Products proj.Products = p.Products
@@ -307,6 +475,11 @@ func (c *BackendCrmProjectController) Delete() {
pipelineErr(&c.Controller, 404, 404, "项目未找到") pipelineErr(&c.Controller, 404, 404, "项目未找到")
return return
} }
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据")
return
}
if !canDeleteCrmRecord(claims, proj.CreateUserID) { if !canDeleteCrmRecord(claims, proj.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该项目") pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该项目")
return return
+5 -5
View File
@@ -148,8 +148,8 @@ func Init(_ string) {
EnsureCrmCreateUserColumn() EnsureCrmCreateUserColumn()
EnsureCrmProjectDocColumn() EnsureCrmProjectDocColumn()
EnsureCrmContractTable() EnsureCrmContractTable()
EnsureCrmClueAmountColumn()
EnsureCrmContractOurRoleColumn() EnsureCrmContractOurRoleColumn()
EnsureCrmContractTechDateColumn()
EnsureCrmProjectProductsColumn() EnsureCrmProjectProductsColumn()
EnsureCrmPaybackTable() EnsureCrmPaybackTable()
EnsureCrmPaybackRecordTable() EnsureCrmPaybackRecordTable()
@@ -234,11 +234,11 @@ func EnsureCrmContractOurRoleColumn() {
_, _ = Orm.Raw(sql).Exec() _, _ = Orm.Raw(sql).Exec()
} }
// EnsureCrmContractTechDateColumn 补齐合同表的技术日期字段(开发/交付完成日期; // EnsureCrmClueAmountColumn 补齐线索表的预计金额字段(线索阶段预估金额;
// 存量表已建时新增,表不存在或列已存在时忽略错误)。 // 存量表已建时新增,表不存在或列已存在时忽略错误)。
func EnsureCrmContractTechDateColumn() { func EnsureCrmClueAmountColumn() {
sql := "ALTER TABLE " + new(TenantCrmContract).TableName() + sql := "ALTER TABLE " + new(TenantCrmClue).TableName() +
" ADD COLUMN tech_date date DEFAULT NULL COMMENT '技术日期(开发/交付完成日期;到期且回款未满则自动转执行异常)'" " ADD COLUMN amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '预计金额'"
_, _ = Orm.Raw(sql).Exec() _, _ = Orm.Raw(sql).Exec()
} }
+1
View File
@@ -13,6 +13,7 @@ type TenantCrmClue struct {
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"` // 负责人 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"` OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
Industry string `orm:"column(industry);size(50)" json:"industry"` // 客户行业 Industry string `orm:"column(industry);size(50)" json:"industry"` // 客户行业
Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"` // 预计金额(线索阶段预估,用于仪表盘线索金额统计)
NextContactTime *time.Time `orm:"column(next_contact_time);type(datetime);null" json:"next_contact_time"` // 下次联系时间 NextContactTime *time.Time `orm:"column(next_contact_time);type(datetime);null" json:"next_contact_time"` // 下次联系时间
ClueLevel string `orm:"column(clue_level);size(20);default(2)" json:"clue_level"` // 客户级别:1重点/2普通/3非优先 ClueLevel string `orm:"column(clue_level);size(20);default(2)" json:"clue_level"` // 客户级别:1重点/2普通/3非优先
ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"` // 客户对接人 ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"` // 客户对接人
-1
View File
@@ -25,7 +25,6 @@ type TenantCrmContract struct {
SignDate *time.Time `orm:"column(sign_date);type(date);null" json:"sign_date"` SignDate *time.Time `orm:"column(sign_date);type(date);null" json:"sign_date"`
EffectiveDate *time.Time `orm:"column(effective_date);type(date);null" json:"effective_date"` EffectiveDate *time.Time `orm:"column(effective_date);type(date);null" json:"effective_date"`
ExpireDate *time.Time `orm:"column(expire_date);type(date);null" json:"expire_date"` ExpireDate *time.Time `orm:"column(expire_date);type(date);null" json:"expire_date"`
TechDate *time.Time `orm:"column(tech_date);type(date);null" json:"tech_date"` // 技术日期(开发/交付完成日期;到期且回款未满则自动转执行异常)
Parties string `orm:"column(parties);type(text);null" json:"-"` // 各方签约主体 JSON 数组 Parties string `orm:"column(parties);type(text);null" json:"-"` // 各方签约主体 JSON 数组
Products string `orm:"column(products);type(text);null" json:"-"` // 产品清单 JSON 数组 Products string `orm:"column(products);type(text);null" json:"-"` // 产品清单 JSON 数组
HardwareAmount float64 `orm:"column(hardware_amount);digits(14);decimals(2);default(0)" json:"hardware_amount"` HardwareAmount float64 `orm:"column(hardware_amount);digits(14);decimals(2);default(0)" json:"hardware_amount"`
+22 -1
View File
@@ -15,12 +15,33 @@ type TenantCrmProject struct {
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"` 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"` OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
Industry string `orm:"column(industry);size(50)" json:"industry"` Industry string `orm:"column(industry);size(50)" json:"industry"`
// 项目类型:1=单一项目(只有上游客户) 2=上下游项目(上游客户 + 下游供应商)
ProjectType int8 `orm:"column(project_type);default(1)" json:"project_type"`
// 下游主体:可从供应商库或客户库选择(supplier_ref_type + supplier_id 标明来源),也允许手工填写名称
SupplierID *uint64 `orm:"column(supplier_id);null" json:"supplier_id"`
SupplierRefType int8 `orm:"column(supplier_ref_type);default(0)" json:"supplier_ref_type"` // 0未关联 1客户 2供应商
SupplierName string `orm:"column(supplier_name);size(100)" json:"supplier_name"`
SupplierIndustry string `orm:"column(supplier_industry);size(50)" json:"supplier_industry"`
// 金额拆分为上下游:上游=对客户的收入,下游=对供应商的成本(上下游项目毛利 = 上游 - 下游)
UpstreamAmount float64 `orm:"column(upstream_amount);digits(14);decimals(2);default(0)" json:"upstream_amount"`
DownstreamAmount float64 `orm:"column(downstream_amount);digits(14);decimals(2);default(0)" json:"downstream_amount"`
// Amount 为旧「项目金额」列,已由 UpstreamAmount 取代;写入时始终同步为上游金额,仅作历史兼容
Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"` 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已暂停 Status int8 `orm:"column(status);default(1)" json:"status"` // 1未开始/2进行中/3已完成/4已暂停
// StartDate / EndDate 为旧的手工维护列,已改由关联合同自动汇总,保留仅用于兼容历史数据
StartDate *time.Time `orm:"column(start_date);type(date);null" json:"start_date"` 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"` EndDate *time.Time `orm:"column(end_date);type(date);null" json:"end_date"`
// 项目实际起止时间:按其下所有合同汇总(最早生效日期 / 最晚结束日期),只读不落库
ContractStartDate *time.Time `orm:"-" json:"contract_start_date"`
ContractEndDate *time.Time `orm:"-" json:"contract_end_date"`
// 上下游对接人:仅需姓名与电话
UpstreamContact string `orm:"column(upstream_contact);size(50)" json:"upstream_contact"`
UpstreamPhone string `orm:"column(upstream_phone);size(20)" json:"upstream_phone"`
DownstreamContact string `orm:"column(downstream_contact);size(50)" json:"downstream_contact"`
DownstreamPhone string `orm:"column(downstream_phone);size(20)" json:"downstream_phone"`
// 以下为旧版单一对接人信息,已被上下游对接人取代,仅保留列以兼容历史数据
ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"` ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"`
ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位 ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位(已废弃)
ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"` ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
Address string `orm:"column(address);size(255)" json:"address"` Address string `orm:"column(address);size(255)" json:"address"`
Remark string `orm:"column(remark);type(text);null" json:"remark"` Remark string `orm:"column(remark);type(text);null" json:"remark"`