优化项目管理
This commit is contained in:
@@ -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)})吗?`,
|
||||
|
||||
Reference in New Issue
Block a user