批量优化产品和产品分类

This commit is contained in:
2026-09-12 00:44:13 +08:00
parent 184c604bee
commit 2b1874943f
33 changed files with 2595 additions and 59 deletions
@@ -59,7 +59,7 @@
<el-button :icon="Plus" @click="emit('create')" />
</el-tooltip>
</div>
<div v-if="party.ref_name" class="picked-name">已关联:{{ party.ref_name }}</div>
<!-- <div v-if="party.ref_name" class="picked-name">已关联:{{ party.ref_name }}</div> -->
</template>
<!-- 签约人 -->
@@ -2,29 +2,61 @@
<div class="product-list">
<el-table :data="products" border row-key="__key" :empty-text="'暂无产品,点击下方按钮添加'">
<el-table-column label="序号" type="index" width="50" align="center" />
<el-table-column label="产品名称" min-width="160">
<el-table-column label="产品类别" width="150">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="请输入产品/服务名称" maxlength="100" />
</template>
</el-table-column>
<el-table-column label="产品类别" width="140">
<template #default="{ row }">
<el-select v-model="row.category" placeholder="类别">
<el-select
v-model="row.category"
filterable
clearable
placeholder="请选择分类"
style="width: 100%"
@change="() => onCategoryChange(row)"
>
<el-option
v-for="i in CONTRACT_PRODUCT_CATEGORY_OPTIONS"
:key="i.value"
:label="i.label"
:value="i.value"
v-for="c in categoryOptions"
:key="c.id"
:label="c.name"
:value="c.name"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="产品名称" min-width="210">
<template #default="{ row }">
<el-select
v-model="row.name"
filterable
allow-create
default-first-option
clearable
:disabled="!row.category"
:placeholder="row.category ? '搜索或输入产品名称' : '请先选择产品类别'"
style="width: 100%"
@focus="onProductFocus(row)"
@visible-change="(v) => { if (v) onProductFocus(row) }"
@change="(val) => onProductPick(row, val)"
>
<el-option
v-for="p in productOptions"
:key="p.id"
:label="p.product_name"
:value="p.product_name"
>
<span class="opt-name">{{ p.product_name }}</span>
<span class="opt-meta">{{ p.spec || "—" }} · ¥{{ p.price }}</span>
</el-option>
</el-select>
<!-- <div v-if="row.product_id" class="linked-tip">
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
</div> -->
</template>
</el-table-column>
<el-table-column label="规格型号" width="130">
<template #default="{ row }">
<el-input v-model="row.spec" :placeholder="specPlaceholder(row)" />
</template>
</el-table-column>
<el-table-column label="单位" width="80">
<el-table-column label="单位" width="90">
<template #default="{ row }">
<el-input v-model="row.unit" placeholder="套" maxlength="10" />
</template>
@@ -53,14 +85,29 @@
/>
</template>
</el-table-column>
<el-table-column label="成本单价(元)" width="140">
<el-table-column label="成本单价(元)" width="150">
<template #default="{ row }">
<el-tooltip content="成本单价取自产品管理,关联产品后不可修改" :disabled="!row.product_id">
<el-input-number
v-model="row.cost_price"
:min="0"
:precision="2"
:controls="false"
:disabled="!!row.product_id"
placeholder="0.00"
style="width: 100%"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="税率(%)" width="110">
<template #default="{ row }">
<el-input-number
v-model="row.cost_price"
v-model="row.tax_rate"
:min="0"
:max="100"
:precision="2"
:controls="false"
placeholder="0.00"
style="width: 100%"
/>
</template>
@@ -84,7 +131,9 @@
<div class="add-row">
<el-button :icon="Plus" @click="addRow">添加产品</el-button>
<span class="add-tip">按类别自动归集:硬件 / 软件单列金额,服务、开发等计入其他</span>
<span class="add-tip">
先选产品类别,再在该分类下搜索/选择产品(自动带出规格、单位、成本、税率);分类内无对应名称可输入新增,提交时自动建档到产品管理
</span>
</div>
<!-- 金额汇总 -->
@@ -112,7 +161,10 @@
</div>
<div class="summary-item profit">
<span class="summary-label">合同总利润</span>
<span class="summary-value" :class="{ loss: summary.total_profit < 0 }">
<span
class="summary-value"
:class="{ loss: summary.total_profit < 0 }"
>
¥{{ formatMoney(summary.total_profit) }}
</span>
</div>
@@ -122,10 +174,12 @@
</template>
<script setup>
import { computed } from "vue";
import { ref, computed, onMounted } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { buildSummary } from "./utils";
import { getProductList } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
const props = defineProps({
/** 产品行数组(父组件持有) */
@@ -134,17 +188,23 @@ const props = defineProps({
const summary = computed(() => buildSummary(props.products));
const categoryOptions = ref([]);
const productOptions = ref([]);
const activeRow = ref(null); // 当前正在操作的产品行(用于按分类过滤搜索)
let rowSeed = 0;
const addRow = () => {
props.products.push({
__key: `row_${Date.now()}_${rowSeed++}`,
name: "",
product_id: null,
category: "",
spec: "",
unit: "",
quantity: 1,
price: 0,
cost_price: 0,
tax_rate: 0,
remark: "",
});
};
@@ -157,10 +217,91 @@ const rowAmount = (row) =>
Math.round(((Number(row.quantity) || 0) * (Number(row.price) || 0) || 0) * 100) / 100;
const specPlaceholder = (row) => {
if (String(row.category) === "1") return "如:型号 / 配置";
if (String(row.category) === "4") return "如:功能模块 / 里程碑";
const cat = String(row.category || "");
if (cat.includes("硬件")) return "如:型号 / 配置";
if (cat.includes("软件") || cat.includes("开发")) return "如:功能模块 / 版本";
return "规格 / 说明";
};
/** 加载产品分类(与产品管理一致,实现类别数据同步) */
async function fetchCategories() {
try {
const res = await getProductCategoryList({ page: 1, pageSize: 200, status: 1 });
categoryOptions.value = res?.data?.list || [];
} catch (e) {
categoryOptions.value = [];
}
}
/** 切换分类:清空已选产品及带出的字段,强制「分类优先」,并重载该分类下的产品选项 */
function onCategoryChange(row) {
row.name = "";
row.product_id = null;
row.spec = "";
row.unit = "";
row.price = 0;
row.cost_price = 0;
row.tax_rate = 0;
activeRow.value = row;
ensureOptions(row);
}
/** 产品下拉聚焦 / 下拉展开:记录当前行并加载该分类下的全部产品选项 */
function onProductFocus(row) {
activeRow.value = row;
ensureOptions(row);
}
/** 按分类加载产品选项(本地 filterable 即可做到可写文字 + 模糊搜索,allow-create 支持自定义新增) */
async function ensureOptions(row) {
const cat = row.category;
if (!cat) return;
try {
const res = await getProductList({ category: cat, pageSize: 200 });
productOptions.value = res?.data?.list || [];
} catch (e) {
productOptions.value = [];
}
}
/** 选中/输入产品名称:命中产品管理则带回规格/单位/单价/成本/税率(成本只读);分类内无此名称则可新增 */
function onProductPick(row, val) {
if (!val) {
row.product_id = null;
return;
}
const hit = productOptions.value.find((p) => p.product_name === val);
if (hit) {
row.product_id = hit.id;
row.spec = hit.spec || "";
row.unit = hit.unit || "";
row.price = hit.price || 0;
row.cost_price = hit.cost_price || 0;
row.tax_rate = hit.tax_rate || 0;
} else {
// 该分类下无此名称:标记为新增,提交时由后端自动建档到产品管理(成本可编辑)
row.product_id = null;
}
}
onMounted(() => {
fetchCategories();
// 为已加载的合同产品预置下拉选项,保证名称回显
props.products.forEach((r) => {
if (r && r.name && !productOptions.value.some((o) => o.product_name === r.name)) {
productOptions.value.push({
id: r.product_id || 0,
product_name: r.name,
category: r.category,
spec: r.spec,
unit: r.unit,
price: r.price,
cost_price: r.cost_price,
tax_rate: r.tax_rate,
});
}
});
});
</script>
<style lang="less" scoped>
@@ -170,6 +311,22 @@ const specPlaceholder = (row) => {
color: var(--el-text-color-primary);
}
.opt-name {
float: left;
}
.opt-meta {
float: right;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.linked-tip {
margin-top: 2px;
font-size: 12px;
color: var(--el-color-success);
}
.add-row {
display: flex;
align-items: center;
@@ -57,12 +57,12 @@
/>
</div>
</div>
<div class="nature-tip">
<!-- <div class="nature-tip">
<el-icon><InfoFilled /></el-icon>
<span>
我方({{ tenantName }})已自动填入{{ ownPartyRoleLabel }};其余各方从客户库或供应商库中搜索关联,也可点击 + 快速新建。
</span>
</div>
</div> -->
<!-- 项目关联 -->
<el-divider content-position="left">
@@ -241,6 +241,8 @@ const props = defineProps({
editData: { type: Object, default: null },
/** 打开时定位的步骤(1=合同信息 2=产品清单) */
initStep: { type: Number, default: 1 },
/** 新建合同时预设关联项目(从项目详情「合同管理」进入时使用) */
presetProject: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "success"]);
@@ -281,6 +283,18 @@ const emptyForm = () => ({
const form = reactive(emptyForm());
/**
* 日期归一化为 YYYY-MM-DD。
* 后端返回的是 RFC3339(如 2026-09-12T00:00:00+08:00),日期选择器只认年月日,
* 回显与提交前都需截断,否则后端解析失败会把日期写成 NULL。
*/
function normalizeDate(val) {
if (!val) return "";
const s = String(val).trim();
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
return m ? `${m[1]}-${m[2]}-${m[3]}` : s;
}
/** 参与方(甲乙丙丁):ref_type 0=本公司 1=客户 2=供应商,非我方默认客户可切换 */
const emptyParty = (role) => ({
role,
@@ -438,6 +452,9 @@ watch(
form.status = Number(props.editData.status) || 1;
form.step = Number(props.editData.step) || 1;
form.project_id = props.editData.project_id || null;
form.sign_date = normalizeDate(props.editData.sign_date);
form.effective_date = normalizeDate(props.editData.effective_date);
form.expire_date = normalizeDate(props.editData.expire_date);
parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p }));
products.value = (props.editData.products || []).map((row, i) => ({
...row,
@@ -453,6 +470,17 @@ watch(
Object.assign(form, emptyForm());
form.contract_no = genContractNo();
projectOptions.value = [];
// 从项目详情「合同管理」进入:预设关联项目,直接按「项目合同」创建
if (props.presetProject?.id) {
form.project_id = props.presetProject.id;
form.project_name = props.presetProject.project_name || "";
projectOptions.value = [
{
id: props.presetProject.id,
project_name: form.project_name || `项目 #${props.presetProject.id}`,
},
];
}
}
// 兜底补齐参与方并写入本公司方
@@ -499,6 +527,10 @@ const prevStep = () => {
/** 组装保存 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),
our_role: Number(form.our_role) || 2,
party_count: Number(form.party_count) || 2,
parties: parties.value.map((p) => ({ ...p })),
@@ -8,6 +8,7 @@
* - 合同总金额 = 硬件 + 软件 + 其他
* - 产品总成本 = Σ (数量 × 成本单价)
* - 合同总利润 = 合同总金额 - 产品总成本
* 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他(与后端一致)。
*/
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
@@ -25,8 +26,8 @@ export function buildSummary(products) {
const amount = round2(qty * price);
totalCost += round2(qty * costPrice);
const cat = String(row?.category || "");
if (cat === "1") hardware += amount;
else if (cat === "2") software += amount;
if (cat === "1" || cat.includes("硬件")) hardware += amount;
else if (cat === "2" || cat.includes("软件")) software += amount;
else other += amount;
});
hardware = round2(hardware);