更新各项功能

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);
}
+36 -11
View File
@@ -300,8 +300,12 @@ func (c *BackendCrmContractController) Create() {
return
}
row.Products = products
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行)
projID := uint64(0)
if row.ProjectID != nil {
projID = *row.ProjectID
}
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), projID, row.ProjectName, json.RawMessage(products)); serr == nil {
row.Products = string(synced)
}
applyContractAmounts(&row)
@@ -397,15 +401,18 @@ func (c *BackendCrmContractController) Update() {
return
}
row.Products = products
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理;含软件开发行)
projID := uint64(0)
if row.ProjectID != nil {
projID = *row.ProjectID
}
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), projID, row.ProjectName, json.RawMessage(products)); serr == nil {
row.Products = string(synced)
}
}
applyContractAmounts(&row)
if p.Status != 0 {
row.Status = p.Status
}
// 状态不随合同编辑变更(避免编辑保存把已流转的状态打回草稿);
// 签订/履约/作废等流转统一由「状态流转」接口 ChangeStatus 控制。
if p.Step != 0 {
row.Step = p.Step
}
@@ -606,12 +613,30 @@ func applyContractAmounts(row *models.TenantCrmContract) {
}
var hardware, software, other, cost float64
for _, item := range items {
// 软件开发行:金额来自模块树汇总(统一人天单价×人天,不计税率/成本,与前端 buildSummary 一致)
// 软件开发行:开发总成本由模块报价带出(默认 = 人天×统一人天单价,可手工改写),计入产品成本;
// 对外金额取销售单价(缺省时回退开发总成本 / 人天汇总,兼容历史数据);不计税率。
// 口径与前端 utils.js 的 lineAmount / lineCost / buildSummary 保持一致。
if fmt.Sprintf("%v", item["line_type"]) == "dev" {
if tree, ok := item["dev_tree"].([]interface{}); ok {
unitPrice := toFloat64(item["dev_unit_price"])
software = round2(software + contractTreeSellTotal(tree, unitPrice))
qty := toFloat64(item["quantity"])
if qty <= 0 {
qty = 1
}
total := toFloat64(item["dev_total_price"])
if total <= 0 {
if tree, ok := item["dev_tree"].([]interface{}); ok {
total = contractTreeSellTotal(tree, toFloat64(item["dev_unit_price"]))
}
}
costPrice := toFloat64(item["cost_price"])
if costPrice <= 0 {
costPrice = total
}
price := toFloat64(item["price"])
if price <= 0 {
price = total
}
software = round2(software + round2(qty*price))
cost = round2(cost + round2(qty*costPrice))
continue
}
qty := toFloat64(item["quantity"])
+96 -13
View File
@@ -375,10 +375,13 @@ func SyncProjectProducts(tenantID string, projectID uint64, projectName string,
// SyncContractProducts 把合同产品清单同步到产品管理(供合同保存时调用):
// - 已关联 product_id 或名称命中现有产品:回填 product_id,并将成本单价强制取产品管理
// (成本追溯产品管理;销售单价不回写,允许合同溢价);
// - 名称未命中(产品管理中不存在):新建产品档案,销售单价/成本单价/规格/单位/分类/税率取自合同行。
// - 名称未命中(产品管理中不存在):新建产品档案,销售单价/成本单价/规格/单位/分类/税率取自合同行;
// 软件开发行(line_type=dev)同样建档,并写入统一人天单价与模块树,使该「开发包」可在产品管理中复用;
// - 软件开发行已关联到产品档案时,回写最新模块树与人天单价(价格仍由产品管理主导,不回写);
// - projectID 传入时回填为产品档案的「来源项目」。
//
// 返回(可能已回填 product_id 与成本单价)的清单 JSON,供合同落库。
func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMessage, error) {
func SyncContractProducts(tenantID, uid string, projectID uint64, projectName string, raw json.RawMessage) (json.RawMessage, error) {
var items []map[string]interface{}
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "[]" || strings.TrimSpace(string(raw)) == "" {
return json.RawMessage("[]"), nil
@@ -388,11 +391,11 @@ func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMe
}
now := time.Now()
for _, it := range items {
// 软件开发行(line_type=dev)不走产品管理建档,仅保留模块树
if fmt.Sprintf("%v", it["line_type"]) == "dev" {
continue
}
name := strings.TrimSpace(fmt.Sprintf("%v", it["name"]))
// 行类型:成品 product / 软件开发 dev,缺省按成品处理并回填给清单
lineType := normalizeLineType(productFieldStr(it["line_type"]))
it["line_type"] = lineType
name := productFieldStr(it["name"])
if name == "" {
continue
}
@@ -404,35 +407,60 @@ func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMe
Filter("delete_time__isnull", true).One(&p); e == nil && p.ID > 0 {
it["product_id"] = p.ID
it["cost_price"] = p.CostPrice
syncDevTreeToProduct(&p, it)
}
continue
}
// 按名称命中现有产品(未启用也命中,便于回填)
// 按名称命中现有产品(未启用也命中,便于回填):需与当前行类型一致,避免成品/开发互相误匹配
var exist models.TenantCrmProduct
e := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("tenant_id", tenantID).Filter("product_name", name).
Filter("delete_time__isnull", true).OrderBy("-id").Limit(1).One(&exist)
if e == nil && exist.ID > 0 {
if e == nil && exist.ID > 0 && normalizeLineType(exist.LineType) == lineType {
it["product_id"] = exist.ID
it["cost_price"] = exist.CostPrice
syncDevTreeToProduct(&exist, it)
continue
}
// 未命中:新建产品档案(成本/单价等取自合同行)
row := models.TenantCrmProduct{
TenantID: tenantID,
ProductNo: genProductNo(tenantID),
ProductName: name,
Category: strings.TrimSpace(fmt.Sprintf("%v", it["category"])),
Unit: strings.TrimSpace(fmt.Sprintf("%v", it["unit"])),
Spec: strings.TrimSpace(fmt.Sprintf("%v", it["spec"])),
LineType: lineType,
Category: productFieldStr(it["category"]),
Unit: productFieldStr(it["unit"]),
Spec: productFieldStr(it["spec"]),
Price: toFloat64(it["price"]),
CostPrice: toFloat64(it["cost_price"]),
TaxRate: toFloat64(it["tax_rate"]),
Status: 1,
Remark: strings.TrimSpace(fmt.Sprintf("%v", it["remark"])),
Remark: productFieldStr(it["remark"]),
CreateUserID: uid,
CreateTime: now,
UpdateTime: now,
}
// 软件开发行:写入统一人天单价与模块树,作为可复用的「开发包」
if lineType == "dev" {
row.DevUnitPrice = toFloat64(it["dev_unit_price"])
if tree := it["dev_tree"]; tree != nil {
if b, me := json.Marshal(tree); me == nil {
row.DevTree = string(b)
}
}
if row.Category == "" {
row.Category = "软件开发"
}
if row.Unit == "" {
row.Unit = "项"
}
}
// 回填来源项目(仅项目合同有,无头合同为空)
if projectID > 0 {
pid := projectID
row.ProjectID = &pid
row.ProjectName = strings.TrimSpace(projectName)
}
id, ierr := models.Orm.Insert(&row)
if ierr != nil {
return raw, ierr
@@ -446,6 +474,61 @@ func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMe
return json.RawMessage(out), nil
}
// normalizeLineType 规范化行类型:空值按成品处理。
func normalizeLineType(v string) string {
if strings.TrimSpace(v) == "" {
return "product"
}
return strings.TrimSpace(v)
}
// productFieldStr 安全取清单字段的字符串值:nil 时返回空串,避免 fmt 输出 "<nil>"。
func productFieldStr(v interface{}) string {
if v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
// syncDevTreeToProduct 软件开发行引用到产品档案时,回写最新模块树与人天单价
// (仅在有变化时更新;销售/成本单价不回写,仍由产品管理主导),
// 保证后续合同再选该产品能拿到最新的开发包。
func syncDevTreeToProduct(p *models.TenantCrmProduct, it map[string]interface{}) {
if p == nil || p.ID == 0 || normalizeLineType(p.LineType) != "dev" {
return
}
tree := it["dev_tree"]
if tree == nil {
return
}
b, me := json.Marshal(tree)
if me != nil {
return
}
newTree := string(b)
newUnitPrice := toFloat64(it["dev_unit_price"])
if newTree == p.DevTree && newUnitPrice == p.DevUnitPrice {
return
}
p.DevTree = newTree
p.DevUnitPrice = newUnitPrice
_, _ = models.Orm.Update(p, "DevTree", "DevUnitPrice")
}
// genProductNo 生成产品编号 P-yyyymmdd-4位序号,并保证本租户内不重复。
func genProductNo(tenantID string) string {
day := time.Now().Format("20060102")
for i := 0; i < 8; i++ {
no := fmt.Sprintf("P-%s-%04d", day, (time.Now().UnixNano()+int64(i)*37)%10000)
cnt, err := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("tenant_id", tenantID).Filter("product_no", no).Count()
if err == nil && cnt == 0 {
return no
}
}
return fmt.Sprintf("P-%s-%d", day, time.Now().UnixNano()%100000)
}
// toUint64 转为 uint64(依赖同包 toInt64)。
func toUint64(v interface{}) uint64 {
return uint64(toInt64(v))