优化项目管理

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,7 +6,7 @@
<el-tag v-else-if="party?.ref_name" size="small" type="info" effect="plain">
{{ party.ref_type === 2 ? "供应商" : "客户" }}
</el-tag>
<span v-if="locked" class="party-company">{{ tenantName }}</span>
<span v-if="locked" class="party-company">{{ party?.ref_name || tenantName }}</span>
</div>
<!-- 关联主体:当前租户方锁定,其余模糊搜索客户库 / 供应商库 -->
@@ -62,13 +62,30 @@
<!-- <div v-if="party.ref_name" class="picked-name">已关联:{{ party.ref_name }}</div> -->
</template>
<!-- 签约人 -->
<!-- 签约人:本公司方从组织架构人员中选择,其余方从关联企业联系人中选择,均自动带出联系电话;也支持手动输入 -->
<div class="party-row signer-row">
<el-input
<el-select
v-model="party.signer_name"
placeholder="签约人姓名"
maxlength="30"
/>
filterable
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" />
</div>
</div>
@@ -78,6 +95,8 @@
import { ref, computed, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
import { listContacts } from "@/api/contact";
import { erpOrganizationApi } from "@/api/organization";
import { useAuthStore } from "@/stores/auth";
const props = defineProps({
@@ -102,6 +121,83 @@ const party = computed(() => props.modelValue);
const searching = ref(false);
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(() =>
Number(party.value.ref_type) === 2 ? "供应商" : "客户"
);
@@ -121,7 +217,11 @@ const ensureOption = () => {
watch(
() => party.value?.ref_id,
() => ensureOption(),
() => {
ensureOption();
// 关联企业变化时同步刷新其联系人,供签约人下拉选择
fetchContacts();
},
{ immediate: true }
);
@@ -160,14 +260,20 @@ const handlePick = (id) => {
const handleClear = () => {
party.value.ref_id = null;
party.value.ref_name = "";
party.value.signer_name = "";
party.value.signer_phone = "";
options.value = [];
contacts.value = [];
};
/** 丙/丁方切换客户 / 供应商来源时清空已选 */
const handleRefTypeChange = () => {
party.value.ref_id = null;
party.value.ref_name = "";
party.value.signer_name = "";
party.value.signer_phone = "";
options.value = [];
contacts.value = [];
};
</script>
@@ -170,9 +170,9 @@
<div class="add-row">
<el-button :icon="Plus" @click="addRow">添加产品</el-button>
<span class="add-tip">
<!-- <span class="add-tip">
每行可选「成品」或「软件开发」:成品按数量×单价计价;软件开发点「模块报价」编辑分层模块树(叶子填人天/人天单价,父级自动汇总),统一计入合同总价
</span>
</span> -->
</div>
<!-- 金额汇总 -->
@@ -388,8 +388,9 @@ function onProductPick(row, val) {
const hit = productOptions.value.find((p) => p.product_name === val);
if (hit) {
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.dev_unit_price = Number(hit.dev_unit_price) || 0;
row.dev_tree = Array.isArray(hit.dev_tree)
@@ -409,6 +410,14 @@ function onProductPick(row, val) {
if (!row.unit) row.unit = "项";
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.unit = hit.unit || "";
row.price = hit.price || 0;
@@ -179,18 +179,6 @@
</el-row>
</el-form-item>
</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-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 { createContract, updateContract } from "@/api/crmContract";
import { getProjectList } from "@/api/crmPipeline";
import { erpOrganizationApi } from "@/api/organization";
import { useAuthStore } from "@/stores/auth";
import {
CONTRACT_CATEGORY_OPTIONS,
@@ -294,7 +283,6 @@ const emptyForm = () => ({
sign_date: "",
effective_date: "",
expire_date: "",
tech_date: "",
remark: "",
status: 1,
step: 1,
@@ -346,6 +334,24 @@ const ownPartyRoleLabel = computed(() => {
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 activeRoles = computed(() => PARTY_ROLES.slice(0, Number(form.party_count) || 2));
@@ -454,19 +460,19 @@ const setPartyCount = (v) => {
applyOwnParty();
};
/** 把当前租户写入本公司方 */
/** 把当前租户(组织架构中的企业单位)写入本公司方 */
const applyOwnParty = () => {
const own = parties.value.find((p) => p.role === ownPartyRole.value);
if (own) {
own.ref_type = 0;
own.ref_id = 0;
own.ref_name = tenantName.value;
own.ref_name = ownCompanyName.value;
}
};
watch(
() => props.visible,
(val) => {
async (val) => {
if (!val) return;
activeStep.value = Math.max(0, Math.min(MAX_STEP, (props.initStep || 1) - 1));
internalId.value = null;
@@ -487,7 +493,6 @@ watch(
form.sign_date = normalizeDate(props.editData.sign_date);
form.effective_date = normalizeDate(props.editData.effective_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 }));
products.value = (props.editData.products || []).map((row, i) => ({
...row,
@@ -516,6 +521,8 @@ watch(
}
}
// 先取组织架构中的企业单位,再写入本公司方(避免先用登录租户名填充、后被覆盖)
await loadOwnCompany();
// 兜底补齐参与方并写入本公司方
const count = Number(form.party_count) || 2;
parties.value = PARTY_ROLES.slice(0, count).map(
@@ -576,7 +583,6 @@ const buildPayload = (step, status) => {
sign_date: normalizeDate(form.sign_date),
effective_date: normalizeDate(form.effective_date),
expire_date: normalizeDate(form.expire_date),
tech_date: normalizeDate(form.tech_date),
our_role: Number(form.our_role) || 2,
party_count: Number(form.party_count) || 2,
parties: parties.value.map((p) => ({ ...p })),
@@ -84,7 +84,7 @@
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ref, reactive, watch, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue";
import {
@@ -127,9 +127,12 @@ const logRef = ref();
const econtractRef = ref();
const invoiceRef = ref();
// 同时监听展开状态与目标合同 id:
// 页面被 keep-alive 缓存时,从其他入口再打开另一份合同时 visible 可能一直是 true(未变化),
// 只监听 visible 会导致不重新加载、内容串成上一份合同。
watch(
() => props.visible,
async (val) => {
[() => props.visible, () => props.contract?.id],
async ([val]) => {
if (!val) return;
activeTab.value = "basic";
counts.payback = 0;
@@ -146,6 +149,9 @@ watch(
} finally {
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.effective_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>
@@ -39,7 +39,7 @@
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
@@ -92,7 +92,23 @@
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<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>
</el-table-column>
<template #empty>
@@ -420,8 +436,12 @@ async function submitReceive() {
}
}
/** 删除一条回款记录(会同步回写计划的已回款金额) */
/** 删除一条回款记录(会同步回写计划的已回款金额;所属计划已完成时禁止) */
async function handleDeleteRecord(row) {
if (Number((planOf(row.payback_id) || {}).status) === 2) {
ElMessage.warning("所属回款计划已完成(回款已收齐),不允许删除回款记录");
return;
}
try {
await ElMessageBox.confirm(
`确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`,