批量更新功能

This commit is contained in:
hero920103 committed 2026-09-14 16:15:44 +08:00
1 parent 6f106e43af
commit a26c98b92b
79 files changed
+4529 -724

No files matched your search

@@ -88,6 +88,11 @@
</el-select>
<el-input v-model="party.signer_phone" placeholder="联系电话" maxlength="20" />
</div>
<!-- 该企业还没有联系人:签约人姓名与电话会在保存合同时写入企业通讯录 -->
<div v-if="showContactTip" class="contact-tip">
该企业暂无联系人,填写姓名与电话即可,保存合同后会自动写入通讯录,可稍后补全其他资料
</div>
</div>
</template>
@@ -143,6 +148,15 @@ const contactPlaceholder = computed(() => {
return party.value?.ref_id ? "选择签约人(也可输入)" : "请先选择客户 / 供应商";
});
/** 已选企业但通讯录里还没有联系人:提示签约人信息会在保存合同后自动写入通讯录 */
const showContactTip = computed(
() =>
!isOwnCompany.value &&
!!party.value?.ref_id &&
!contactLoading.value &&
contacts.value.length === 0
);
/** 拉取本公司人员(在职员工),映射为与联系人一致的字段结构,便于复用下拉渲染 */
async function fetchOwnEmployees() {
ownLoading.value = true;
@@ -338,5 +352,13 @@ const handleRefTypeChange = () => {
.select-footer {
text-align: center;
}
/* 暂无联系人提示:签约人信息保存后自动写入通讯录 */
.contact-tip {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
}
</style>
@@ -10,6 +10,15 @@
</el-select>
</template>
</el-table-column>
<el-table-column label="研发方式" width="120">
<template #default="{ row }">
<!-- 软件开发分两种:自行研发 / 外协研发(别人帮我们研发,成本按外包总额计) -->
<el-select v-if="row.line_type === 'dev'" v-model="row.dev_mode" style="width: 100%">
<el-option v-for="i in DEV_MODE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="产品类别" width="150">
<template #default="{ row }">
<el-select
@@ -74,7 +83,24 @@
</el-table-column>
<el-table-column label="单位" width="90">
<template #default="{ row }">
<el-input v-model="row.unit" :placeholder="row.line_type === 'dev' ? '项' : '套'" maxlength="10" />
<!-- 单位下拉取自「基本设置 - 单位设置」(启用中的单位),支持搜索与直接输入 -->
<el-select
v-model="row.unit"
filterable
allow-create
clearable
:placeholder="row.line_type === 'dev' ? '项' : '套'"
style="width: 100%"
>
<el-option v-for="u in unitOptions" :key="u" :label="u" :value="u" />
<!-- 历史数据中的单位不在单位设置里时兜底展示,避免已填单位看不到 -->
<el-option
v-if="row.unit && !unitOptions.includes(row.unit)"
:key="row.unit"
:label="row.unit"
:value="row.unit"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="数量" width="110">
@@ -198,13 +224,14 @@
<span class="summary-label">产品总成本</span>
<span class="summary-value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<!-- 我方为甲方(付款方)时显示「合同支出」(负数),否则显示「合同总利润」 -->
<div class="summary-item profit">
<span class="summary-label">合同总利润</span>
<span class="summary-label">{{ contractProfitLabel(ourRole) }}</span>
<span
class="summary-value"
:class="{ loss: summary.total_profit < 0 }"
:class="{ loss: !isPayerContract(ourRole) && summary.total_profit < 0 }"
>
¥{{ formatMoney(summary.total_profit) }}
{{ contractProfitText(ourRole, summary) }}
</span>
</div>
</div>
@@ -226,20 +253,31 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import {
formatMoney,
PRODUCT_UNIT_OPTIONS,
DEV_MODE_OPTIONS,
contractProfitLabel,
contractProfitText,
isPayerContract,
} from "../../dict";
import { buildSummary, lineAmount, devCost, treeSellTotal } from "./utils";
import { getProductList } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import { getUnitList } from "@/api/crmUnit";
import DevModuleTree from "./DevModuleTree.vue";
const props = defineProps({
/** 产品行数组(父组件持有) */
products: { type: Array, required: true },
/** 我方角色:1=甲方(付款方,金额汇总显示「合同支出」,不显示利润) */
ourRole: { type: [Number, String], default: 2 },
});
const summary = computed(() => buildSummary(props.products));
const categoryOptions = ref([]);
const unitOptions = ref([]);
const productOptions = ref([]);
const activeRow = ref(null); // 当前正在操作的产品行(用于按分类过滤搜索)
@@ -259,6 +297,7 @@ const addRow = () => {
tax_rate: 0,
dev_tree: [],
dev_unit_price: 0,
dev_mode: "self", // 软件开发研发方式:self=自行研发(默认)/ outsource=外协研发
dev_total_price: 0, // 模块报价带出的开发总成本(= 本行成本单价)
dev_auto_total: 0, // 最近一次按人天自动汇总的值,用于判断是否需要跟随更新
remark: "",
@@ -269,11 +308,12 @@ const removeRow = (index) => {
props.products.splice(index, 1);
};
/** 切换行类型:软件开发行确保有模块树容器,并补上类别/单位默认值 */
/** 切换行类型:软件开发行确保有模块树容器与研发方式,并补上类别/单位默认值 */
function onLineTypeChange(row) {
if (row.line_type === "dev") {
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
if (row.dev_unit_price == null) row.dev_unit_price = 0;
if (!row.dev_mode) row.dev_mode = "self";
// 开发行不进产品管理建档,类别与单位需在此兜底,否则详情展示为空
if (!row.category) row.category = "软件开发";
if (!row.unit) row.unit = "项";
@@ -333,6 +373,21 @@ async function fetchCategories() {
}
}
/** 加载计量单位下拉:基本设置「单位设置」中启用中的单位 + 内置默认单位(去重) */
async function fetchUnits() {
let names = [];
try {
const res = await getUnitList({ page: 1, pageSize: 200, status: 1 });
names = (res?.data?.list || []).map((i) => i.name).filter(Boolean);
} catch (e) {
names = [];
}
PRODUCT_UNIT_OPTIONS.forEach((i) => {
if (!names.includes(i.value)) names.push(i.value);
});
unitOptions.value = names;
}
/** 切换分类:清空已选产品及带出的字段,强制「分类优先」,并重载该分类下的产品选项 */
function onCategoryChange(row) {
row.name = "";
@@ -393,6 +448,8 @@ function onProductPick(row, val) {
if (hit.line_type === "dev") {
row.line_type = "dev";
row.dev_unit_price = Number(hit.dev_unit_price) || 0;
// 研发方式随产品档案带出(自行研发 / 外协研发),可在本行调整
row.dev_mode = hit.dev_mode === "outsource" ? "outsource" : "self";
row.dev_tree = Array.isArray(hit.dev_tree)
? JSON.parse(JSON.stringify(hit.dev_tree))
: safeParseTree(hit.dev_tree);
@@ -442,6 +499,7 @@ function safeParseTree(v) {
onMounted(() => {
fetchCategories();
fetchUnits();
// 为已加载的合同产品预置下拉选项,保证名称回显
props.products.forEach((r) => {
// 兼容历史数据:开发行补全「开发总成本 / 成本单价 / 销售单价」,避免显示为 0
@@ -454,6 +512,8 @@ onMounted(() => {
if (!Number(r.price)) r.price = total;
if (!r.category) r.category = "软件开发";
if (!r.unit) r.unit = "项";
// 历史数据补默认研发方式(自行研发)
if (!r.dev_mode) r.dev_mode = "self";
}
if (r && r.name && !productOptions.value.some((o) => o.product_name === r.name)) {
productOptions.value.push({
@@ -8,7 +8,7 @@
@closed="handleClosed"
>
<el-alert
:title="`创建成功后将自动关联到合同参与方`"
:title="`创建成功后将自动关联到${associateTip}`"
type="info"
:closable="false"
show-icon
@@ -32,7 +32,7 @@
<el-input v-model="form.contact_person" placeholder="请输入联系人(选填)" />
</el-form-item>
<el-form-item label="联系电话" prop="contact_phone">
<el-input v-model="form.contact_phone" placeholder="请输入联系电话(选填)" />
<el-input v-model="form.contact_phone" placeholder="请输入联系电话" />
</el-form-item>
</el-form>
<template #footer>
@@ -48,6 +48,7 @@
import { ref, reactive, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { createCrmCustomer, createCrmSupplier } from "@/api/crm";
import { createContact } from "@/api/contact";
import { CUSTOMER_TYPE_OPTIONS, SUPPLIER_TYPE_OPTIONS } from "../../dict";
const props = defineProps({
@@ -56,6 +57,8 @@ const props = defineProps({
refType: { type: [Number, String], default: 1 },
/** 从搜索词带入名称 */
presetName: { type: String, default: "" },
/** 创建成功后自动关联的目标(提示文案,如:合同参与方 / 项目客户) */
associateTip: { type: String, default: "合同参与方" },
});
const emit = defineEmits(["update:visible", "created"]);
@@ -79,6 +82,8 @@ const form = reactive(defaultForm());
const rules = {
name: [{ required: true, message: "名称不能为空", trigger: "blur" }],
// 后端正式保存要求联系电话非空,这里提前提示,避免提交后才报错
contact_phone: [{ required: true, message: "联系人电话不能为空", trigger: "blur" }],
};
watch(
@@ -99,6 +104,33 @@ function handleClosed() {
Object.assign(form, defaultForm());
}
/** 是否手机号(11 位、1 开头);非手机号按座机提交,保证联系人资料分列正确 */
function isMobilePhone(phone) {
return /^1\d{10}$/.test(String(phone || "").replace(/[\s-]/g, ""));
}
/**
* 新建企业时填写的联系人一并写入企业通讯录(仅姓名 + 电话,其余资料可后续在联系人中补全);
* 写入失败不阻断企业创建结果。
*/
async function syncContactToBook(companyID) {
const name = String(form.contact_person || "").trim();
if (!companyID || !name) return;
const phone = String(form.contact_phone || "").trim();
try {
await createContact({
related_type: Number(props.refType) === 2 ? 2 : 1,
related_id: companyID,
contact_name: name,
mobile: phone && isMobilePhone(phone) ? phone : "",
phone: phone && !isMobilePhone(phone) ? phone : "",
is_primary: 1, // 新企业还没有联系人,首个联系人默认设为主联系人
});
} catch (e) {
// 通讯录写入失败不影响企业创建,用户可稍后在联系人中手动补录
}
}
async function handleSubmit() {
if (!formRef.value) return;
try {
@@ -127,11 +159,17 @@ async function handleSubmit() {
const res = await createCrmCustomer(payload);
row = res?.data || { id: null, customer_name: form.name };
}
const createdID = Number(row?.id ?? row?.customer_id ?? row?.supplier_id ?? 0);
// 新建企业时填写的联系人写入企业通讯录(先写入再回调,调用方刷新联系人时即可选到)
await syncContactToBook(createdID);
ElMessage.success(`${refTypeText.value}创建成功`);
emit("created", {
...row,
id: row?.id ?? row?.customer_id ?? row?.supplier_id ?? null,
id: createdID || null,
label: form.name,
// 一并回传联系人信息,供调用方自动带出对接人 / 电话
contact_person: form.contact_person,
contact_phone: form.contact_phone,
});
handleClose();
} catch (e) {
@@ -45,12 +45,30 @@
<div class="config-block">
<span class="config-label">我方角色</span>
<el-radio-group v-model="form.our_role">
<el-radio-button :value="0">无</el-radio-button>
<el-radio-button v-for="i in roleOptionsForCount" :key="i.value" :value="i.value">
{{ i.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 我方不参与:如上中游项目中上游与中游主体之间签订的合同,甲乙双方均非本公司 -->
<el-alert
v-if="Number(form.our_role) === 0"
type="info"
:closable="false"
show-icon
title="我方不参与本合同签约:甲乙双方均从客户库 / 供应商库中选择,本公司不作为签约主体。"
style="margin: 0 16px 16px"
/>
<el-alert
v-else-if="selectedProjectType === 3"
type="warning"
:closable="false"
show-icon
title="该项目为上中游项目:若登记的是上游公司与中游主体之间签订的合同,我方不参与签约,请将「我方角色」切换为「无」;若是我方与中游主体之间的合同,保持当前角色即可。"
style="margin: 0 16px 16px"
/>
<div class="parties-grid">
<div v-for="role in activeRoles" :key="role.key">
<PartySelect
@@ -188,7 +206,7 @@
<!-- 步骤 2:产品清单 -->
<div v-show="activeStep === 1">
<ProductList :products="products" />
<ProductList :products="products" :our-role="form.our_role" />
</div>
</el-form>
@@ -321,8 +339,12 @@ const initParties = () => {
parties.value = PARTY_ROLES.slice(0, count).map((r) => emptyParty(r.key));
};
/** 当前租户在合同中的角色:由「我方角色」决定(1甲方/2乙方/3丙方/4丁方) */
/**
* 当前租户在合同中的角色:由「我方角色」决定(1甲方/2乙方/3丙方/4丁方)。
* 0=我方不参与(如上中游项目的链条合同):不锁定任何一方,全部参与方从客户/供应商库选择。
*/
const ownPartyRole = computed(() => {
if (Number(form.our_role) === 0) return "";
const idx = Math.min(Math.max(Number(form.our_role) || 2, 1), 4);
return PARTY_ROLES[idx - 1].key;
});
@@ -399,6 +421,16 @@ const handleProjectChange = (id) => {
form.project_name = hit?.project_name || "";
};
/**
* 当前选中项目的类型:1=单一 2=上下游 3=上中游。
* 上中游项目存在「上游与中游主体之间」的合同,我方不参与签约,需提示切换角色。
*/
const selectedProjectType = computed(() => {
if (!form.project_id) return 0;
const hit = projectOptions.value.find((i) => String(i.id) === String(form.project_id));
return Number(hit?.project_type) || 0;
});
/* ------------------------------ 联动 ------------------------------ */
/** 我方角色切换:原我方还原为待选方(保留签约人),新我方写入当前租户,其余各方不受影响 */
@@ -406,7 +438,9 @@ watch(
() => form.our_role,
(nv, ov) => {
if (!props.visible) return;
const roleKeyOf = (v) => PARTY_ROLES[Math.min(Math.max(Number(v) || 2, 1), 4) - 1].key;
// 0=我方不参与:没有对应的参与方
const roleKeyOf = (v) =>
Number(v) === 0 ? "" : PARTY_ROLES[Math.min(Math.max(Number(v) || 2, 1), 4) - 1].key;
const prevKey = roleKeyOf(ov);
const nextKey = roleKeyOf(nv);
if (prevKey === nextKey) return;
@@ -460,8 +494,9 @@ const setPartyCount = (v) => {
applyOwnParty();
};
/** 把当前租户(组织架构中的企业单位)写入本公司方 */
/** 把当前租户(组织架构中的企业单位)写入本公司方;我方不参与时不写入任何一方 */
const applyOwnParty = () => {
if (!ownPartyRole.value) return;
const own = parties.value.find((p) => p.role === ownPartyRole.value);
if (own) {
own.ref_type = 0;
@@ -482,8 +517,9 @@ watch(
isEdit.value = true;
internalId.value = props.editData.id;
Object.assign(form, emptyForm(), props.editData);
// 类型归一:后端返回数字,统一转为对应类型确保回显匹配
form.our_role = Number(props.editData.our_role) || 2;
// 类型归一:后端返回数字,统一转为对应类型确保回显匹配(0=我方不参与是合法值)
form.our_role =
props.editData.our_role == null ? 2 : Number(props.editData.our_role);
form.contract_category =
props.editData.contract_category == null ? "" : String(props.editData.contract_category);
form.party_count = Number(props.editData.party_count) || 2;
@@ -516,6 +552,7 @@ watch(
{
id: props.presetProject.id,
project_name: form.project_name || `项目 #${props.presetProject.id}`,
project_type: props.presetProject.project_type,
},
];
}
@@ -583,7 +620,7 @@ const buildPayload = (step, status) => {
sign_date: normalizeDate(form.sign_date),
effective_date: normalizeDate(form.effective_date),
expire_date: normalizeDate(form.expire_date),
our_role: Number(form.our_role) || 2,
our_role: Number(form.our_role) === 0 ? 0 : Number(form.our_role) || 2,
party_count: Number(form.party_count) || 2,
parties: parties.value.map((p) => ({ ...p })),
products: products.value.map(({ __key, ...rest }) => ({
@@ -7,7 +7,7 @@
{{ contractStatusText(detail.status) }}
</el-tag> -->
<el-tag v-if="contractOverdue(detail).overdue" type="danger" size="small" effect="dark">
{{ overdueText(contractOverdue(detail).days) }}
{{ contractOverdue(detail).text }}
</el-tag>
</div>
<!-- <div class="detail-sub">
@@ -25,7 +25,11 @@
<el-descriptions-item label="履约状态">
<el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="我方角色">{{ ourRoleText(detail.our_role) }}</el-descriptions-item>
<el-descriptions-item label="我方角色">
<el-tag :type="ourRoleTag(detail.our_role)" size="small" effect="plain">
{{ ourRoleText(detail.our_role) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="所属项目" :span="2">
<span v-if="detail.project_id">{{ detail.project_name }}</span>
<span v-else>-</span>
@@ -66,7 +70,10 @@
<el-table-column label="产品名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.name || (isDevRow(row) ? "软件开发" : "-") }}</span>
<!-- <el-tag v-if="isDevRow(row)" size="small" effect="plain" type="danger" class="dev-flag">开发</el-tag> -->
<!-- 软件开发行区分研发方式:自研 / 外协(别人帮我们研发) -->
<el-tag v-if="isDevRow(row)" size="small" effect="plain" :type="devModeTag(row.dev_mode)" class="dev-flag">
{{ devModeShortText(row.dev_mode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类别" width="100" align="center">
@@ -103,9 +110,12 @@
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<!-- 我方为甲方(付款方)时显示「合同支出」(负数),否则显示「合同总利润」 -->
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
<span class="label">{{ contractProfitLabel(detail.our_role) }}</span>
<span class="value success">
{{ contractProfitText(detail.our_role, summary) }}
</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
@@ -130,13 +140,17 @@ import {
formatMoney,
formatDateOnly,
contractCategoryText,
devModeShortText,
devModeTag,
ourRoleText,
ourRoleTag,
contractStatusText,
contractStatusTag,
contractOverdue,
overdueText,
productCategoryText,
productCategoryTag,
contractProfitLabel,
contractProfitText,
} from "../../dict";
const props = defineProps({
@@ -11,37 +11,68 @@
</el-tooltip>
</div>
<el-table v-loading="loading" :data="list" border size="small">
<el-table-column label="状态" width="90" align="center">
<!-- 树形:计划为总条目(父行),其明细节点(进度 / 分期)以子条目挂在下方 -->
<el-table
v-loading="loading"
:data="treeRows"
border
size="small"
row-key="id"
:tree-props="{ children: '__itemRows' }"
default-expand-all
>
<el-table-column label="名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
<span v-if="!row.__isItem" class="plan-name">{{ paybackCycleText(row.plan_type) }}回款计划</span>
<span v-else class="item-name">{{ row.name || `节点${row.seq}` }}</span>
</template>
</el-table-column>
<el-table-column label="回款周期" width="90" align="center">
<el-table-column label="状态" width="85" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
<el-tag v-if="!row.__isItem" :type="paybackStatusTag(row.status)" size="small">
{{ paybackStatusText(row.status) }}
</el-tag>
<el-tag v-else :type="paybackItemStatusTag(row)" size="small">
{{ paybackItemStatusText(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="回款进度" min-width="140" align="center">
<el-table-column label="周期 / 进度" width="110" align="center">
<template #default="{ row }">
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
<el-tag v-if="!row.__isItem" size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
<span v-else class="item-percent">{{ Number(row.percent) > 0 ? `${Number(row.percent)}%` : "-" }}</span>
</template>
</el-table-column>
<el-table-column label="计划回款总额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
<el-table-column label="回款进度" min-width="125" align="center">
<template #default="{ row }">
<el-progress v-if="!row.__isItem" :percentage="paybackPercent(row)" :stroke-width="10" />
<el-progress
v-else
:percentage="paybackItemPercent(row)"
:stroke-width="8"
:status="itemStatusOf(row) === 2 ? 'success' : undefined"
/>
</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
<el-table-column label="计划回款总额" width="115" align="right">
<template #default="{ row }">{{ formatMoney(row.__isItem ? row.amount : row.total_amount) }}</template>
</el-table-column>
<el-table-column label="回款方式" width="100" align="center">
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
<el-table-column label="已回款" width="105" align="right">
<template #default="{ row }">
<span v-if="!row.__isItem">{{ formatMoney(row.received_amount) }}</span>
<span v-else class="amount">{{ formatMoney(itemReceivedOf(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
<el-table-column label="计划回款日期" width="105" align="center">
<template #default="{ row }">
<span v-if="row.__isItem">{{ row.plan_date || "-" }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<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>
<!-- 仅顶级(计划)有详情按钮;回款登记统一走「回款进度」区块的新建回款 -->
<el-button v-if="!row.__isItem" link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
<template #empty>
@@ -59,7 +90,7 @@
</div>
<el-tooltip :disabled="list.length > 0" content="回款进度以回款计划为先导条件,请先新建回款计划" placement="top">
<span class="btn-wrap">
<el-button type="primary" size="small" :icon="Plus" :disabled="!list.length" @click="openReceive">
<el-button type="primary" size="small" :icon="Plus" :disabled="!list.length" @click="openReceive()">
新建回款
</el-button>
</span>
@@ -71,7 +102,10 @@
<template #default="{ row }">{{ row.received_date || "-" }}</template>
</el-table-column>
<el-table-column label="对应回款计划" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ planText(planOf(row.payback_id)) }}</template>
<template #default="{ row }">
{{ planText(planOf(row.payback_id)) }}
<span v-if="row.item_name" class="item-tag">· {{ row.item_name }}</span>
</template>
</el-table-column>
<el-table-column label="本次回款金额" width="130" align="right">
<template #default="{ row }">
@@ -149,6 +183,22 @@
<el-option v-for="p in list" :key="p.id" :label="planText(p)" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="进度节点">
<!-- 按现有进度选择性付款:不指定时按整单登记 -->
<el-select
v-model="receiveForm.item_seq"
clearable
placeholder="不指定(按整单登记)"
style="width: 100%"
>
<el-option
v-for="it in planItems(receiveForm.payback_id)"
:key="it.seq"
:label="`${it.name || '节点' + it.seq}(¥${formatMoney(it.amount)})`"
:value="it.seq"
/>
</el-select>
</el-form-item>
<el-form-item label="本次回款" required>
<el-input-number
v-model="receiveForm.amount"
@@ -296,6 +346,68 @@ function planText(p) {
)}`;
}
/** 金额保留两位小数 */
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/* ---------------------- 树形结构:计划(总条目) → 明细节点(子条目) ---------------------- */
/**
* 树形行数据:计划为父行,其 items 明细(进度节点 / 分期)为子行;
* 子行 row-key 全局唯一(`{计划ID}-item-{序号}`);父行的回款记录(children)仅用于节点已回款汇总,
* 不参与树形展示(tree-props 显式指向 __itemRows)。
*/
const treeRows = computed(() =>
list.value.map((p) => ({
...p,
__isItem: false,
__itemRows: (Array.isArray(p.items) ? p.items : []).map((it) => ({
id: `${p.id}-item-${it.seq}`,
__isItem: true,
__plan: p,
...it,
})),
}))
);
/** 指定计划的明细节点列表(供新建回款选择进度节点) */
function planItems(paybackId) {
const p = list.value.find((i) => Number(i.id) === Number(paybackId));
return Array.isArray(p?.items) ? p.items : [];
}
/** 节点已回款:按「标记到该节点的回款记录」汇总,兼容历史上按明细直接登记(received_amount)的值 */
function itemReceivedOf(row) {
const plan = row.__plan;
const fromRecords = (Array.isArray(plan?.children) ? plan.children : [])
.filter((r) => Number(r.item_seq) === Number(row.seq))
.reduce((s, r) => s + (Number(r.amount) || 0), 0);
const legacy = Number(row.received_amount) || 0;
return Math.max(round2(fromRecords), legacy);
}
/** 节点回款进度百分比 */
function paybackItemPercent(row) {
const total = Number(row.amount) || 0;
if (!total) return 0;
return Math.min(100, Math.round((itemReceivedOf(row) / total) * 100));
}
/** 节点状态:0未回款 1部分回款 2已收齐 */
function itemStatusOf(row) {
const total = Number(row.amount) || 0;
const received = itemReceivedOf(row);
if (total > 0 && received >= total) return 2;
return received > 0 ? 1 : 0;
}
function paybackItemStatusText(row) {
return { 0: "未回款", 1: "部分回款", 2: "已收齐" }[itemStatusOf(row)] || "-";
}
function paybackItemStatusTag(row) {
return { 0: "info", 1: "warning", 2: "success" }[itemStatusOf(row)] || "info";
}
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后禁止再新建回款计划 */
const paidOff = computed(() => {
const planned = Number(progressSummary.value.total_planned) || 0;
@@ -386,6 +498,7 @@ const receiveVisible = ref(false);
const receiveSaving = ref(false);
const receiveForm = reactive({
payback_id: null,
item_seq: 0, // 进度节点序号(1-based),0=不指定(按整单登记)
amount: 0,
received_date: "",
pay_method: 1,
@@ -394,7 +507,10 @@ const receiveForm = reactive({
remark: "",
});
/** 新建回款:以回款计划为先导条件(无计划时不允许登记) */
/**
* 新建回款:以回款计划为先导条件(无计划时不允许登记)。
* 计划与进度节点(按进度选择性付款)都在弹窗内选择。
*/
function openReceive() {
if (!list.value.length) {
ElMessage.warning("请先新建回款计划,回款进度以计划为先导条件");
@@ -403,6 +519,7 @@ function openReceive() {
const first = list.value[0];
Object.assign(receiveForm, {
payback_id: first.id,
item_seq: 0,
amount: 0,
received_date: todayStr(),
pay_method: first.pay_method || 1,
@@ -413,10 +530,11 @@ function openReceive() {
receiveVisible.value = true;
}
/** 切换回款计划:同步该计划的回款方式作为默认值 */
/** 切换回款计划:同步该计划的回款方式作为默认值,并清空进度节点选择 */
function onPlanChange(pid) {
const p = planOf(pid);
if (p) receiveForm.pay_method = p.pay_method || 1;
receiveForm.item_seq = 0;
}
const uploading = ref(false);
@@ -515,6 +633,7 @@ async function submitReceive() {
try {
await createPaybackRecord({
payback_id: receiveForm.payback_id,
item_seq: Number(receiveForm.item_seq) || 0,
amount: Number(receiveForm.amount) || 0,
received_date: receiveForm.received_date || "",
pay_method: receiveForm.pay_method,
@@ -602,6 +721,29 @@ defineExpose({ reload });
color: #409eff;
}
/* 树形父行:计划名称 */
.plan-name {
font-weight: 600;
color: var(--el-text-color-primary);
}
/* 树形子条目:进度节点名称 + 百分比 */
.item-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.item-percent {
margin-left: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.item-tag {
margin-left: 2px;
color: var(--el-color-primary);
}
.receipt-block {
width: 100%;
+92 -22
View File
@@ -14,6 +14,7 @@
<!-- 统计周期 -->
<div class="stat-period-bar">
<el-radio-group v-model="statsPeriod" @change="onPeriodChange">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="quarter">本季</el-radio-button>
<el-radio-button label="year">本年</el-radio-button>
@@ -30,10 +31,12 @@
:clearable="false"
@change="onRangeChange"
/>
<!-- 当前统计范围:切换周期后卡片按该区间重新统计(区间相同则数字自然相同) -->
<span class="stat-scope">统计范围:{{ statsScopeText }}</span>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-cards" v-loading="statsLoading">
<div class="stat-card">
<span class="stat-label">合同总数</span>
<span class="stat-value">{{ stats.total ?? 0 }}</span>
@@ -47,7 +50,12 @@
<span class="stat-value">{{ formatMoney(stats.total_cost) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">合同总利润</span>
<el-tooltip
content="仅统计非甲方(收款方)合同的利润;我方为甲方的合同属于支出,不计入利润"
placement="top"
>
<span class="stat-label">合同总利润</span>
</el-tooltip>
<span class="stat-value" :style="profitStyle(stats.total_profit)">{{ profitText(stats.total_profit) }}</span>
</div>
</div>
@@ -65,8 +73,8 @@
/>
</el-form-item>
<el-form-item label="我方角色">
<el-select v-model="filters.our_role" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in OUR_ROLE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
<el-select v-model="filters.our_role" clearable placeholder="全部" style="width: 130px">
<el-option v-for="i in ourRoleFilterOptions" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="合同分类">
@@ -101,8 +109,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip />
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip fixed>
<el-table-column label="我方角色" width="90" align="center" fixed>
<template #default="{ row }">
<el-tag :type="ourRoleTag(row.our_role)" size="small" effect="plain">{{ ourRoleText(row.our_role) }}</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip /> -->
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip >
<template #default="{ row }">
<span class="name-link" @click="openRow(row)">
{{ row.contract_name }}
@@ -110,11 +123,6 @@
</span>
</template>
</el-table-column>
<el-table-column label="我方角色" width="90" align="center">
<template #default="{ row }">
<el-tag type="primary" size="small" effect="plain">{{ ourRoleText(row.our_role) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="100" align="center">
<template #default="{ row }">{{ contractCategoryText(row.contract_category) }}</template>
</el-table-column>
@@ -128,20 +136,28 @@
{{ formatMoney(row.summary?.total_amount ?? row.total_amount) }}
</template>
</el-table-column>
<el-table-column label="合同总利润" width="130" align="right">
<el-table-column label="合同利润/支出" width="140" align="right">
<template #header>
<el-tooltip
content="我方为甲方(付款方):按负数(绿色)显示合同支出 = 合同总金额;其余角色:显示合同利润 = 总金额 − 产品总成本"
placement="top"
>
<span>合同利润/支出</span>
</el-tooltip>
</template>
<template #default="{ row }">
<span :style="profitStyle(row.summary?.total_profit ?? row.total_profit)">
{{ profitText(row.summary?.total_profit ?? row.total_profit) }}
<span :style="profitStyle(contractProfitOrExpense(row))">
{{ profitText(contractProfitOrExpense(row)) }}
</span>
</template>
</el-table-column>
<el-table-column label="签订日期" width="110" align="center">
<template #default="{ row }">{{ formatDateOnly(row.sign_date) }}</template>
</el-table-column>
<el-table-column label="逾期" width="100" align="center">
<el-table-column label="逾期" width="130" align="center">
<template #default="{ row }">
<el-tag v-if="contractOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(contractOverdue(row).days) }}
{{ contractOverdue(row).text }}
</el-tag>
<span v-else>-</span>
</template>
@@ -184,7 +200,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, nextTick, watch } from "vue";
import { ref, reactive, computed, onMounted, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
@@ -197,16 +213,17 @@ import ContractCreate from "./components/create.vue";
import ContractDetail from "./components/detail.vue";
import {
CONTRACT_CATEGORY_OPTIONS,
OUR_ROLE_OPTIONS,
ourRoleFilterOptions,
CONTRACT_STATUS_OPTIONS,
ourRoleText,
ourRoleTag,
contractCategoryText,
contractStatusText,
contractStatusTag,
contractOverdue,
overdueText,
formatDateOnly,
formatMoney,
isPayerContract,
} from "../dict";
const route = useRoute();
@@ -228,15 +245,46 @@ const filters = reactive({
});
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 统计周期:month/quarter/year/custom(自定义选区间),默认本年 */
const statsPeriod = ref("year");
/** 统计周期:all(全部)/ month / quarter / year / custom(自定义选区间),默认全部 */
const statsPeriod = ref("all");
const statsRange = ref([]);
/** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */
const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 });
/** 卡片加载态:切换周期时给出可见的刷新反馈 */
const statsLoading = ref(false);
/** 周期参数:统计与列表共用,保证两者口径一致 */
/** 当前统计范围文案:与后端口径一致(合同有效期与所选周期重叠),便于对照卡片数字 */
const statsScopeText = computed(() => {
const pad = (n) => String(n).padStart(2, "0");
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
switch (statsPeriod.value) {
case "month":
return `${fmt(new Date(y, m, 1))} ~ ${fmt(new Date(y, m + 1, 0))}`;
case "quarter": {
const q = Math.floor(m / 3);
return `${fmt(new Date(y, q * 3, 1))} ~ ${fmt(new Date(y, q * 3 + 3, 0))}`;
}
case "year":
return `${y}-01-01 ~ ${y}-12-31`;
case "custom":
return statsRange.value?.length === 2
? `${statsRange.value[0]} ~ ${statsRange.value[1]}`
: "自定义区间";
default:
return "全部时间(不限有效期)";
}
});
/**
* 周期参数:统计与列表共用,保证两者口径一致。
* 「全部」不传周期参数——列表不按有效期过滤,避免有效期不在所选周期内的合同(如历史合同)被隐藏。
*/
function periodParams() {
if (statsPeriod.value === "all") return {};
const params = { period: statsPeriod.value };
if (statsPeriod.value === "custom" && statsRange.value && statsRange.value.length === 2) {
params.start_date = statsRange.value[0];
@@ -246,11 +294,14 @@ function periodParams() {
}
async function loadStats() {
statsLoading.value = true;
try {
const res = await getContractStats(periodParams());
stats.value = res?.data || stats.value;
} catch {
/* 统计失败不影响列表 */
} finally {
statsLoading.value = false;
}
}
@@ -373,6 +424,18 @@ function profitStyle(val) {
return {};
}
/**
* 列表展示值:我方为甲方(付款方)的合同是「支出」,取负值 → 显示为 -¥X(绿色);
* 其余角色的合同是「利润」(总金额 − 产品总成本)。表头已注明口径,单元格内不写"支出"文字。
*/
function contractProfitOrExpense(row) {
if (!isPayerContract(row.our_role)) {
return Number(row.summary?.total_profit ?? row.total_profit) || 0;
}
const paid = Number(row.summary?.total_amount ?? row.total_amount) || 0;
return -paid;
}
function openCreate() {
currentRow.value = null;
initStep.value = 1;
@@ -434,6 +497,13 @@ async function handleDelete(row) {
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
/* 当前统计范围:右对齐说明,切换周期后随卡片一起刷新 */
.stat-scope {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.stat-card {