更新各项功能

This commit is contained in:
2026-09-13 01:29:08 +08:00
parent 4455b95e37
commit aea53e0766
10 changed files with 763 additions and 95 deletions
@@ -11,13 +11,13 @@
<div class="dev-tree">
<div class="tree-toolbar">
<el-button type="primary" size="small" :icon="Plus" @click="addRoot">添加顶级模块</el-button>
<span class="tree-tip">叶子模块只填「人天」,人天单价统一填写;父级自动汇总;可任意层级向下拆分</span>
<!-- <span class="tree-tip">叶子模块只填「人天」,人天单价统一填写;父级自动汇总;可任意层级向下拆分</span> -->
</div>
<div class="unit-price-bar">
<span class="up-label">统一人天单价(元 / 人天)</span>
<el-input-number v-model="unitPrice" :min="0" :precision="2" :controls="false" style="width: 180px" />
<span class="up-total">开发总报价:<b>¥{{ formatMoney(treeSellTotal(tree, unitPrice)) }}</b></span>
<span class="up-total">按人天合计:<b>¥{{ formatMoney(autoTotal) }}</b></span>
</div>
<div v-if="tree.length" class="tree-head">
@@ -37,31 +37,60 @@
/>
<el-empty v-if="!tree.length" description="暂无模块,点击「添加顶级模块」开始" />
<!-- 开发总成本:默认按人天汇总,可手工改写后带出到产品清单 -->
<div class="total-bar">
<div class="tb-main">
<span class="tb-label">开发总成本(元)</span>
<el-input-number
v-model="totalInput"
:min="0"
:precision="2"
:controls="false"
style="width: 200px"
@change="manualTotal = true"
/>
<span class="tb-auto">
按人天自动计算:¥{{ formatMoney(autoTotal) }}(合计 {{ treeManDays(tree) }} 人天)
</span>
<el-button v-if="manualTotal" link type="primary" @click="resetTotal">恢复自动计算</el-button>
</div>
<div class="tb-hint">
该金额将作为此行的「开发成本(成本单价)」带出到产品清单;对外销售单价可在产品清单中自行填写,两者差额即为毛利
</div>
</div>
</div>
<template #footer>
<el-button type="primary" @click="handleConfirm">确定</el-button>
<el-button type="primary" @click="handleConfirm">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { treeSellTotal } from "./utils";
import { treeSellTotal, treeManDays } from "./utils";
import ModuleNode from "./ModuleNode.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
modelValue: { type: Array, default: () => [] },
unitPrice: { type: Number, default: 0 },
/** 开发总成本:默认按人天汇总,可由使用者改写后回传带出 */
totalPrice: { type: Number, default: 0 },
title: { type: String, default: "" },
});
const emit = defineEmits(["update:visible", "update:modelValue", "update:unitPrice"]);
const emit = defineEmits(["update:visible", "update:modelValue", "update:unitPrice", "update:totalPrice"]);
const tree = ref([]);
const unitPrice = ref(0);
/** 按人天 × 统一单价自动汇总的成本 */
const autoTotal = computed(() => treeSellTotal(tree.value, unitPrice.value));
/** 实际采用的成本:未手工改写时等于 autoTotal */
const totalInput = ref(0);
const manualTotal = ref(false);
let uid = 0;
const newNode = () => ({
__id: `m_${Date.now()}_${uid++}`,
@@ -73,10 +102,25 @@ const newNode = () => ({
const syncLocal = () => {
tree.value = Array.isArray(props.modelValue) ? JSON.parse(JSON.stringify(props.modelValue)) : [];
unitPrice.value = Number(props.unitPrice) || 0;
// 传入值与自动汇总不一致,说明此前被手工改写过,保留用户的值
const incoming = Number(props.totalPrice) || 0;
manualTotal.value = incoming > 0 && Math.abs(incoming - autoTotal.value) > 0.005;
totalInput.value = manualTotal.value ? incoming : autoTotal.value;
};
watch(tree, (val) => emit("update:modelValue", val), { deep: true });
watch(unitPrice, (val) => emit("update:unitPrice", val));
watch(totalInput, (val) => emit("update:totalPrice", Number(val) || 0));
// 未手工改写时,开发总成本随人天 / 单价变化自动跟随
watch([tree, unitPrice], () => {
if (!manualTotal.value) totalInput.value = autoTotal.value;
}, { deep: true });
/** 恢复为按人天自动计算 */
const resetTotal = () => {
manualTotal.value = false;
totalInput.value = autoTotal.value;
};
const addRoot = () => tree.value.push(newNode());
@@ -136,6 +180,38 @@ const handleConfirm = () => emit("update:visible", false);
}
}
.total-bar {
padding: 12px;
margin-top: 12px;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 8px;
.tb-main {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
.tb-label {
font-size: 13px;
font-weight: 600;
}
.tb-auto {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.tb-hint {
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
}
.tree-head {
display: flex;
align-items: center;
@@ -33,11 +33,13 @@
</el-table-column>
<el-table-column label="产品名称" min-width="210">
<template #default="{ row }">
<template v-if="row.line_type === 'dev'">
<el-button link type="primary" @click="openDev(row)">
📁 模块报价({{ (row.dev_tree || []).length }} 项 · ¥{{ formatMoney(lineAmount(row)) }})
</el-button>
</template>
<!-- 软件开发行:产品名称由用户自行填写 -->
<el-input
v-if="row.line_type === 'dev'"
v-model="row.name"
placeholder="填写开发项目名称,如:XX 管理系统"
maxlength="80"
/>
<template v-else>
<el-select
v-model="row.name"
@@ -76,8 +78,7 @@
</el-table-column>
<el-table-column label="单位" width="90">
<template #default="{ row }">
<el-input v-if="row.line_type === 'product'" v-model="row.unit" placeholder="套" maxlength="10" />
<span v-else class="muted">—</span>
<el-input v-model="row.unit" :placeholder="row.line_type === 'dev' ? '项' : '套'" maxlength="10" />
</template>
</el-table-column>
<el-table-column label="数量" width="110">
@@ -91,21 +92,19 @@
placeholder="0"
style="width: 100%"
/>
<span v-else class="muted">—</span>
<span v-else class="muted">1</span>
</template>
</el-table-column>
<el-table-column label="单价(元)" width="130">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.price"
:min="0"
:precision="2"
:controls="false"
placeholder="0.00"
:placeholder="row.line_type === 'dev' ? '对外销售单价' : '0.00'"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="成本单价(元)" width="150">
@@ -123,7 +122,9 @@
/>
</el-tooltip>
</template>
<span v-else class="muted">—</span>
<el-tooltip v-else content="取模块报价的「开发总成本」,需修改请进入模块报价" placement="top">
<span class="cost-readonly">¥{{ formatMoney(devCost(row)) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="税率(%)" width="110">
@@ -150,8 +151,17 @@
<el-input v-model="row.remark" placeholder="备注" maxlength="100" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center" fixed="right">
<template #default="{ $index }">
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ $index, row }">
<el-button
v-if="row.line_type === 'dev'"
link
type="primary"
size="small"
@click="openDev(row)"
>
📁 模块报价({{ (row.dev_tree || []).length }} 项)
</el-button>
<el-button link type="danger" size="small" @click="removeRow($index)">删除</el-button>
</template>
</el-table-column>
@@ -203,8 +213,10 @@
v-if="editingRow"
v-model="editingRow.dev_tree"
:unit-price="editingRow.dev_unit_price"
:total-price="editingRow.dev_total_price"
v-model:visible="devVisible"
@update:unit-price="(v) => (editingRow.dev_unit_price = v)"
@update:total-price="(v) => onDevTotalChange(editingRow, v)"
:title="editingRow.name ? editingRow.name + ' - 模块报价' : '软件开发模块报价'"
/>
</div>
@@ -214,7 +226,7 @@
import { ref, computed, onMounted } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { buildSummary, lineAmount } from "./utils";
import { buildSummary, lineAmount, devCost, treeSellTotal } from "./utils";
import { getProductList } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import DevModuleTree from "./DevModuleTree.vue";
@@ -246,6 +258,8 @@ const addRow = () => {
tax_rate: 0,
dev_tree: [],
dev_unit_price: 0,
dev_total_price: 0, // 模块报价带出的开发总成本(= 本行成本单价)
dev_auto_total: 0, // 最近一次按人天自动汇总的值,用于判断是否需要跟随更新
remark: "",
});
};
@@ -254,11 +268,14 @@ 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.category) row.category = "软件开发";
if (!row.unit) row.unit = "项";
}
}
@@ -266,12 +283,38 @@ function onLineTypeChange(row) {
const devVisible = ref(false);
const editingRow = ref(null);
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/** 该行的开发总成本是否仍与「自动汇总值」一致(即未被手工改写) */
function isTotalAuto(row) {
const cur = Number(row.dev_total_price) || 0;
const prev = Number(row.dev_auto_total) || 0;
return !cur || Math.abs(cur - prev) < 0.005;
}
function openDev(row) {
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
// 未被手工改写时,先用最新的人天汇总刷新开发总成本
if (isTotalAuto(row)) {
row.dev_total_price = treeSellTotal(row.dev_tree, row.dev_unit_price);
}
row.dev_auto_total = Number(row.dev_total_price) || 0;
editingRow.value = row;
devVisible.value = true;
}
/** 模块报价的开发总成本变化:带出为该行成本单价;销售单价未被手工改写时同步跟随 */
function onDevTotalChange(row, val) {
const total = round2(val);
row.dev_total_price = total;
row.cost_price = total;
// 销售单价仍等于上次自动带出的值(或尚未填写)→ 跟随更新;已手工改写则保留用户的值
if (!Number(row.price) || Math.abs(Number(row.price) - Number(row.dev_auto_total)) < 0.005) {
row.price = total;
}
row.dev_auto_total = total;
}
const specPlaceholder = (row) => {
const cat = String(row.category || "");
if (cat.includes("硬件")) return "如:型号 / 配置";
@@ -336,6 +379,18 @@ function onProductPick(row, val) {
row.dev_tree = Array.isArray(hit.dev_tree)
? JSON.parse(JSON.stringify(hit.dev_tree))
: safeParseTree(hit.dev_tree);
// 带出开发总成本作为成本单价;销售单价默认与之相同,可在清单中自行改写
const total =
Number(hit.dev_total_price) > 0
? round2(hit.dev_total_price)
: treeSellTotal(row.dev_tree, row.dev_unit_price);
row.dev_total_price = total;
row.dev_auto_total = total;
row.cost_price = total;
row.price = total;
// 此处是直接赋值 line_type,不走下拉的 change,需自行兜底类别/单位
if (!row.category) row.category = "软件开发";
if (!row.unit) row.unit = "项";
return;
}
row.spec = hit.spec || "";
@@ -364,6 +419,17 @@ onMounted(() => {
fetchCategories();
// 为已加载的合同产品预置下拉选项,保证名称回显
props.products.forEach((r) => {
// 兼容历史数据:开发行补全「开发总成本 / 成本单价 / 销售单价」,避免显示为 0
if (r?.line_type === "dev") {
const total =
Number(r.dev_total_price) > 0 ? round2(r.dev_total_price) : treeSellTotal(r.dev_tree, r.dev_unit_price);
r.dev_total_price = total;
r.dev_auto_total = total;
if (!Number(r.cost_price)) r.cost_price = total;
if (!Number(r.price)) r.price = total;
if (!r.category) r.category = "软件开发";
if (!r.unit) r.unit = "项";
}
if (r && r.name && !productOptions.value.some((o) => o.product_name === r.name)) {
productOptions.value.push({
id: r.product_id || 0,
@@ -401,6 +467,11 @@ onMounted(() => {
font-size: 12px;
}
.cost-readonly {
font-weight: 600;
color: var(--el-text-color-primary);
}
.linked-tip {
margin-top: 2px;
font-size: 12px;
@@ -564,27 +564,35 @@ const goStep = (index) => {
activeStep.value = target;
};
/** 组装保存 payload;数值字段统一转数字(后端 int8 解析),summary 由产品清单实时计算 */
const buildPayload = (step, status) => ({
...form,
// 日期统一按 YYYY-MM-DD 提交,避免回传 RFC3339 导致后端解析为空
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 })),
products: products.value.map(({ __key, ...rest }) => ({
...rest,
quantity: Number(rest.quantity) || 0,
price: Number(rest.price) || 0,
cost_price: Number(rest.cost_price) || 0,
})),
summary: buildSummary(products.value),
step,
status,
});
/**
* 组装保存 payload;数值字段统一转数字(后端 int8 解析),summary 由产品清单实时计算。
* status 为 null 时不提交该字段 —— 后端保持库中原状态不变(编辑已有合同的场景),
* 避免每次编辑保存都把已流转的状态打回草稿。
*/
const buildPayload = (step, status) => {
const payload = {
...form,
// 日期统一按 YYYY-MM-DD 提交,避免回传 RFC3339 导致后端解析为空
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 })),
products: products.value.map(({ __key, ...rest }) => ({
...rest,
quantity: Number(rest.quantity) || 0,
price: Number(rest.price) || 0,
cost_price: Number(rest.cost_price) || 0,
})),
summary: buildSummary(products.value),
step,
status,
};
if (status == null) delete payload.status;
return payload;
};
/** 进度式保存核心:草稿只校验合同名称;完成时全量校验 */
const saveContract = async ({ finish = false }) => {
@@ -608,8 +616,9 @@ const saveContract = async ({ finish = false }) => {
saving.value = true;
try {
const step = finish ? 2 : Math.max(1, activeStep.value + 1);
// 向导保存一律为草稿,签订 / 履约等状态在列表「状态」中流转
const status = 1;
// 状态不由编辑流程控制:仅新建时落为草稿,已有合同沿用原状态;
// 签订 / 履约 / 作废等流转统一在详情的「状态流转」中操作
const status = internalId.value ? null : 1;
const payload = buildPayload(step, status);
if (internalId.value) {
@@ -629,7 +638,7 @@ const saveContract = async ({ finish = false }) => {
handleClose();
} else {
form.step = step;
form.status = status;
if (status != null) form.status = status;
ElMessage.success(isEdit.value && internalId.value ? "进度已保存" : "草稿已保存,可继续填写");
}
return true;
@@ -34,7 +34,13 @@
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
</div>
</div>
<el-tabs v-if="detail.id" v-model="activeTab" tab-position="left" class="detail-tabs">
<el-tabs
v-if="detail.id"
v-model="activeTab"
tab-position="left"
class="detail-tabs"
@tab-change="handleTabChange"
>
<!-- 基本信息(拆分自原详情内容) -->
<el-tab-pane label="基本信息" name="basic">
<DetailBasic :detail="detail" />
@@ -52,6 +58,18 @@
:related-id="detail.id"
:related-type="E_CONTRACT_TYPE"
tip="上传合同扫描件 / 电子合同正本,支持图片与 PDF"
@count="counts.econtract = $event"
/>
</el-tab-pane>
<!-- 发票管理(上传发票、在线预览,与合同关联) -->
<el-tab-pane :label="`发票管理 (${counts.invoice})`" name="invoice">
<DetailAttach
ref="invoiceRef"
:related-id="detail.id"
:related-type="INVOICE_TYPE"
tip="上传销售 / 采购发票(图片或 PDF),点击可在线预览"
@count="counts.invoice = $event"
/>
</el-tab-pane>
@@ -96,15 +114,18 @@ const emit = defineEmits(["update:visible", "refresh", "edit"]);
const RELATED_TYPE = 3;
// 电子合同附件专属关联类型(与线索 1 / 商机 2 / 项目 3 区分)
const E_CONTRACT_TYPE = 4;
// 发票管理附件专属关联类型
const INVOICE_TYPE = 5;
const loading = ref(false);
const detail = ref({});
const activeTab = ref("basic");
const counts = reactive({ payback: 0, econtract: 0 });
const counts = reactive({ payback: 0, econtract: 0, invoice: 0 });
const paybackRef = ref();
const logRef = ref();
const econtractRef = ref();
const invoiceRef = ref();
watch(
() => props.visible,
@@ -113,6 +134,7 @@ watch(
activeTab.value = "basic";
counts.payback = 0;
counts.econtract = 0;
counts.invoice = 0;
detail.value = {};
if (!props.contract?.id) return;
loading.value = true;
@@ -137,6 +159,18 @@ function loadAll() {
paybackRef.value?.reload();
logRef.value?.reload();
econtractRef.value?.reload();
invoiceRef.value?.reload();
}
/** 切换 Tab 时刷新对应面板数据,保证与数量角标一致 */
function handleTabChange(name) {
const refMap = {
payback: paybackRef,
econtract: econtractRef,
invoice: invoiceRef,
log: logRef,
};
refMap[name]?.value?.reload();
}
/** 状态流转候选:排除当前状态 */
@@ -64,25 +64,30 @@
<el-divider content-position="left">产品清单</el-divider>
<el-table :data="detail.products || []" border size="small">
<el-table-column type="index" label="#" width="45" align="center" />
<el-table-column prop="name" label="产品名称" min-width="140" show-overflow-tooltip />
<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> -->
</template>
</el-table-column>
<el-table-column label="类别" width="100" align="center">
<template #default="{ row }">
<el-tag :type="productCategoryTag(row.category)" size="small" effect="plain">
{{ productCategoryText(row.category) }}
<el-tag :type="isDevRow(row) ? 'danger' : productCategoryTag(row.category)" size="small" effect="plain">
{{ productCategoryText(row.category || (isDevRow(row) ? "软件开发" : "")) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="60" align="center" />
<el-table-column label="单位" width="60" align="center">
<template #default="{ row }">{{ unitTextOf(row) }}</template>
</el-table-column>
<el-table-column label="数量" width="80" align="right">
<template #default="{ row }">{{ row.quantity ?? "-" }}</template>
<template #default="{ row }">{{ quantityTextOf(row) }}</template>
</el-table-column>
<el-table-column label="单价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.price) }}</template>
<template #default="{ row }">{{ formatMoney(unitPriceOf(row)) }}</template>
</el-table-column>
<el-table-column label="小计" width="110" align="right">
<template #default="{ row }">
{{ formatMoney((Number(row.quantity) || 0) * (Number(row.price) || 0)) }}
</template>
<template #default="{ row }">{{ formatMoney(lineAmount(row)) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
@@ -121,7 +126,7 @@
<script setup>
import { computed } from "vue";
import { PARTY_ROLES, buildSummary } from "./utils";
import { PARTY_ROLES, buildSummary, lineAmount, devSellPrice } from "./utils";
import {
formatMoney,
formatDateOnly,
@@ -147,6 +152,28 @@ const summary = computed(() => {
});
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
/**
* 以下为「软件开发行」的展示兜底:
* 该类行不走产品管理建档,历史数据中名称/类别/单位可能为空,这里给出合理默认值。
*/
const isDevRow = (row) => row?.line_type === "dev";
function unitTextOf(row) {
return row?.unit || (isDevRow(row) ? "项" : "-");
}
function quantityTextOf(row) {
const q = row?.quantity;
if (q === undefined || q === null || q === "") return isDevRow(row) ? 1 : "-";
return q;
}
/** 单价:开发行取销售单价(未填时回退开发总成本/人天汇总,与 buildSummary 口径一致) */
function unitPriceOf(row) {
if (isDevRow(row)) return devSellPrice(row);
return Number(row?.price) || 0;
}
</script>
<style lang="less" scoped>
@@ -171,6 +198,10 @@ const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || r
}
}
.dev-flag {
margin-left: 6px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -2,7 +2,13 @@
<div class="contract-payback">
<div class="section-head">
<span class="section-title">回款计划</span>
<el-button type="primary" size="small" :icon="Plus" @click="openCreate">新建回款计划</el-button>
<el-tooltip :disabled="!paidOff" content="回款已全部完成(进度 100%),不可再新建回款计划" placement="top">
<span class="btn-wrap">
<el-button type="primary" size="small" :icon="Plus" :disabled="paidOff" @click="openCreate">
新建回款计划
</el-button>
</span>
</el-tooltip>
</div>
<el-table v-loading="loading" :data="list" border size="small">
@@ -43,6 +49,77 @@
</template>
</el-table>
<!-- ============================ 回款进度 ============================ -->
<div class="section-head progress-head">
<span class="section-title">回款进度</span>
<div class="progress-summary">
<span>计划回款总额:<b class="primary">¥{{ formatMoney(progressSummary.total_planned) }}</b></span>
<span>已回款:<b class="success">¥{{ formatMoney(progressSummary.total_received) }}</b></span>
<span>待回款:<b class="warning">¥{{ formatMoney((progressSummary.total_planned || 0) - (progressSummary.total_received || 0)) }}</b></span>
</div>
<el-tooltip :disabled="!paidOff" content="回款已全部完成(进度 100%),不可再新建回款计划" placement="top">
<span class="btn-wrap">
<el-button type="primary" size="small" :icon="Plus" :disabled="paidOff" @click="openCreate">
新建回款计划
</el-button>
</span>
</el-tooltip>
</div>
<el-table v-loading="progressLoading" :data="progressList" border size="small" row-key="rowKey">
<el-table-column label="回款周期" width="90" align="center">
<template #default="{ row }">{{ paybackCycleText(row.plan_type) }}</template>
</el-table-column>
<el-table-column label="明细" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.name || `第${row.seq}期` }}</template>
</el-table-column>
<el-table-column label="进度百分比" width="100" align="center">
<template #default="{ row }">{{ row.percent ? `${row.percent}%` : "-" }}</template>
</el-table-column>
<el-table-column label="计划金额" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="计划日期" width="110" align="center">
<template #default="{ row }">{{ row.plan_date || "-" }}</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="110" align="center">
<template #default="{ row }">{{ (row.received_date && row.received_date !== "<nil>") ? row.received_date : "-" }}</template>
</el-table-column>
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="paybackItemOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(paybackItemOverdue(row).days) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="paybackItemStatusTag(row.status)" size="small">{{ paybackItemStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="流水回执" width="110" align="center">
<template #default="{ row }">
<el-link v-if="row.receipt_url" type="primary" :href="row.receipt_url" target="_blank" :underline="false">查看回执</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openReceive(row)">回款登记</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="该合同暂无回款进度,先新建回款计划" :image-size="60" />
</template>
</el-table>
<!-- 新建 / 编辑回款计划(预设当前合同) -->
<PaybackCreate
v-model:visible="createVisible"
@@ -53,13 +130,68 @@
<!-- 回款计划详情 -->
<PaybackDetail v-model:visible="detailVisible" :payback="currentRow" @edit="handleEdit" />
<!-- 回款登记 -->
<el-dialog v-model="receiveVisible" title="回款登记" width="440px">
<el-form :model="receiveForm" label-width="90px">
<el-form-item label="明细">
<span>{{ receiveForm.name || `第${receiveForm.seq}期` }}</span>
</el-form-item>
<el-form-item label="计划金额">
<span class="amount">¥{{ formatMoney(receiveForm.amount) }}</span>
</el-form-item>
<el-form-item label="实收金额">
<el-input-number
v-model="receiveForm.received_amount"
:min="0"
:precision="2"
:controls="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="receiveForm.received_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="流水回执">
<div class="receipt-block">
<el-upload
:show-file-list="false"
:http-request="handleReceiptUpload"
:disabled="uploading"
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.xls,.xlsx"
>
<el-button :icon="Upload" :loading="uploading" size="small">上传流水回执</el-button>
</el-upload>
<div v-if="receiveForm.receipt_url" class="receipt-file">
<el-link type="primary" :href="receiveForm.receipt_url" target="_blank" :underline="false">
<el-icon><Document /></el-icon>
<span class="receipt-name">{{ receiveForm.receipt_name || "流水回执" }}</span>
</el-link>
<el-button link type="danger" size="small" :icon="Delete" @click="clearReceipt">移除</el-button>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="receiveVisible = false">取消</el-button>
<el-button type="primary" :loading="receiveSaving" @click="submitReceive">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { getPaybackList } from "@/api/crmPayback";
import { computed, ref, reactive } from "vue";
import { ElMessage } from "element-plus";
import { Plus, Upload, Document, Delete } from "@element-plus/icons-vue";
import { getPaybackList, getPaybackProgress, registerPaybackReceive } from "@/api/crmPayback";
import { uploadFile } from "@/api/file";
import PaybackCreate from "../../payback/components/create.vue";
import PaybackDetail from "../../payback/components/detail.vue";
import {
@@ -67,6 +199,10 @@ import {
paybackMethodText,
paybackStatusText,
paybackStatusTag,
paybackItemStatusText,
paybackItemStatusTag,
paybackItemOverdue,
overdueText,
formatMoney,
} from "../../dict";
@@ -84,6 +220,18 @@ const detailVisible = ref(false);
const currentRow = ref(null);
const editRow = ref(null);
/** 回款进度(摊平的分期明细) */
const progressLoading = ref(false);
const progressList = ref([]);
const progressSummary = ref({ total_planned: 0, total_received: 0 });
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后禁止再新建回款计划 */
const paidOff = computed(() => {
const planned = Number(progressSummary.value.total_planned) || 0;
if (planned <= 0) return false;
return (Number(progressSummary.value.total_received) || 0) >= planned;
});
/** 回款进度百分比 */
function paybackPercent(row) {
const total = Number(row.total_amount) || 0;
@@ -92,6 +240,11 @@ function paybackPercent(row) {
}
function openCreate() {
// 兜底拦截:回款已收齐时不允许再建计划
if (paidOff.value) {
ElMessage.warning("回款已全部完成(进度 100%),不可再新建回款计划");
return;
}
editRow.value = null;
createVisible.value = true;
}
@@ -118,6 +271,8 @@ async function reload() {
const contractId = props.contract?.id;
if (!contractId) {
list.value = [];
progressList.value = [];
progressSummary.value = { total_planned: 0, total_received: 0 };
emit("count", 0);
return;
}
@@ -131,6 +286,102 @@ async function reload() {
loading.value = false;
emit("count", list.value.length);
}
await fetchProgress();
}
/** 拉取本合同回款进度 */
async function fetchProgress() {
const contractId = props.contract?.id;
if (!contractId) return;
progressLoading.value = true;
try {
const res = await getPaybackProgress({ contract_id: contractId });
progressList.value = (res?.data?.list || []).map((r) => ({ ...r, rowKey: `${r.payback_id}_${r.seq}` }));
progressSummary.value = {
total_planned: res?.data?.total_planned || 0,
total_received: res?.data?.total_received || 0,
};
} catch (e) {
progressList.value = [];
progressSummary.value = { total_planned: 0, total_received: 0 };
ElMessage.error(e.message || "查询回款进度失败");
} finally {
progressLoading.value = false;
}
}
/* ---------------------------- 回款登记 ---------------------------- */
const receiveVisible = ref(false);
const receiveSaving = ref(false);
const receiveForm = reactive({
payback_id: null,
seq: 0,
name: "",
amount: 0,
received_amount: 0,
received_date: "",
receipt_url: "",
receipt_name: "",
});
function openReceive(row) {
Object.assign(receiveForm, {
payback_id: row.payback_id,
seq: row.seq,
name: row.name,
amount: row.amount,
received_amount: Number(row.received_amount) || 0,
received_date: row.received_date && row.received_date !== "<nil>" ? row.received_date : "",
receipt_url: row.receipt_url || "",
receipt_name: row.receipt_name || "",
});
receiveVisible.value = true;
}
const uploading = ref(false);
async function handleReceiptUpload(options) {
const file = options.file;
uploading.value = true;
try {
const fd = new FormData();
fd.append("file", file);
const res = await uploadFile(fd);
const data = res?.data || {};
receiveForm.receipt_url = data.url || "";
receiveForm.receipt_name = data.name || file.name;
ElMessage.success("上传成功");
} catch (e) {
ElMessage.error(e.message || "上传失败");
} finally {
uploading.value = false;
}
}
function clearReceipt() {
receiveForm.receipt_url = "";
receiveForm.receipt_name = "";
}
async function submitReceive() {
receiveSaving.value = true;
try {
await registerPaybackReceive(receiveForm.payback_id, {
seq: receiveForm.seq,
received_amount: Number(receiveForm.received_amount) || 0,
received_date: receiveForm.received_date || "",
receipt_url: receiveForm.receipt_url,
receipt_name: receiveForm.receipt_name,
});
ElMessage.success("回款登记成功");
receiveVisible.value = false;
// 回款计划(含各期已回款金额、总进度)与回款进度明细一并刷新,无需重开抽屉
await reload();
emit("saved");
} catch (e) {
ElMessage.error(e.message || "登记失败");
} finally {
receiveSaving.value = false;
}
}
defineExpose({ reload });
@@ -141,11 +392,57 @@ defineExpose({ reload });
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
margin: 18px 0 12px;
.section-title {
font-size: 14px;
font-weight: 600;
}
}
.progress-head {
margin-top: 22px;
gap: 12px;
flex-wrap: wrap;
.progress-summary {
display: flex;
flex: 1;
justify-content: center;
gap: 18px;
font-size: 13px;
color: #606266;
b {
font-weight: 600;
}
.primary { color: #409eff; }
.success { color: #67c23a; }
.warning { color: #e6a23c; }
}
}
.btn-wrap {
display: inline-flex;
}
.amount {
font-weight: 600;
color: #409eff;
}
.receipt-block {
width: 100%;
.receipt-file {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
.receipt-name {
margin-left: 4px;
}
}
}
</style>
@@ -6,15 +6,18 @@
* - 软件部分 = Σ 产品类别为「软件(许可)」的小计
* - 其他部分 = Σ 服务 / 开发 / 其他类小计
* - 合同总金额 = 硬件 + 软件 + 其他
* - 产品总成本 = Σ (数量 × 成本单价)
* - 产品总成本 = Σ (数量 × 成本单价),软件开发行按其「开发总成本」计入
* - 合同总利润 = 合同总金额 - 产品总成本
*
* 软件开发(开发类)行口径:模块报价中「开发总成本」(默认人天×统一单价,可手工改写)
* 作为该行的成本单价并计入总成本;对外金额取用户填写的销售单价,两者差额即为毛利。
* 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他(与后端一致)。
*/
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/**
* 软件开发模块树节点售价合计:
* 软件开发模块树按人天的金额汇总(即「开发总成本」的默认口径):
* - 含子节点 → 汇总子节点;
* - 叶子节点 → 人天 × 统一人天单价。
*/
@@ -31,15 +34,53 @@ export function treeSellTotal(nodes, unitPrice = 0) {
);
}
/** 单行金额:成品 = 数量×单价;软件开发 = 模块树售价合计(人天×统一人天单价) */
/**
* 软件开发模块树叶子节点人天合计(父级不重复计,仅用于展示)。
*/
export function treeManDays(nodes) {
const list = Array.isArray(nodes) ? nodes : [];
const sum = list.reduce((acc, n) => {
if (Array.isArray(n.children) && n.children.length) return acc + treeManDays(n.children);
return acc + (Number(n.man_days) || 0);
}, 0);
return Math.round(sum * 10) / 10;
}
/** 单行数量:软件开发行固定按 1 个单位计(历史数据可能缺数量,兜底为 1) */
function lineQty(row) {
if (row?.line_type === "dev") return Number(row?.quantity) || 1;
return Number(row?.quantity) || 0;
}
/**
* 软件开发行的开发总成本(= 模块报价带出的总价):
* 优先取 dev_total_price(可手工改写),回退人天汇总,兼容历史数据。
*/
export function devCost(row) {
const manual = Number(row?.dev_total_price) || 0;
if (manual > 0) return round2(manual);
return treeSellTotal(row?.dev_tree, row?.dev_unit_price);
}
/**
* 软件开发行的销售单价:由用户自行填写;
* 未填写时依次回退「开发总成本 → 人天汇总」,避免历史/未填数据显示为 0。
*/
export function devSellPrice(row) {
const p = Number(row?.price) || 0;
if (p > 0) return round2(p);
return devCost(row);
}
/** 单行金额:成品 = 数量×单价;软件开发 = 数量×销售单价(销售单价默认由开发总成本带出,可自行填写) */
export function lineAmount(row) {
if (row?.line_type === "dev") return treeSellTotal(row.dev_tree, row.dev_unit_price);
if (row?.line_type === "dev") return round2(lineQty(row) * devSellPrice(row));
return round2((Number(row?.quantity) || 0) * (Number(row?.price) || 0));
}
/** 单行成本:成品 = 数量×成本单价;软件开发不计成本 */
/** 单行成本:成品 = 数量×成本单价;软件开发 = 数量×开发总成本(模块报价带出) */
export function lineCost(row) {
if (row?.line_type === "dev") return 0;
if (row?.line_type === "dev") return round2(lineQty(row) * devCost(row));
return round2((Number(row?.quantity) || 0) * (Number(row?.cost_price) || 0));
}
@@ -51,8 +92,8 @@ export function buildSummary(products) {
let totalCost = 0;
rows.forEach((row) => {
if (row?.line_type === "dev") {
const amount = treeSellTotal(row.dev_tree, row.dev_unit_price);
software += amount; // 软件开发计入软件部分,不计成本
software += lineAmount(row); // 软件开发计入软件部分,并按开发总成本计入产品成本
totalCost += lineCost(row);
return;
}
const qty = Number(row?.quantity) || 0;
@@ -324,6 +324,7 @@ async function reload() {
} finally {
loading.value = false;
}
// 无论成功失败都回传数量,避免角标与实际列表不一致
emit("count", total.value);
}