更新各项功能
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user