批量优化产品和产品分类
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user