批量优化产品和产品分类

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
+9
View File
@@ -0,0 +1,9 @@
---
description:
alwaysApply: true
enabled: true
updatedAt: 2026-09-11T14:51:38.120Z
provider:
---
调用前先执行codegraph的mcp
-1
View File
@@ -46,7 +46,6 @@ declare module 'vue' {
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
+4 -4
View File
@@ -24,7 +24,7 @@
"os": "^0.1.2",
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
"pinyin-pro": "^3.29.3",
"pinyin-pro": "^3.29.4",
"tesseract.js": "^7.0.0",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
@@ -6557,9 +6557,9 @@
}
},
"node_modules/pinyin-pro": {
"version": "3.29.3",
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.29.3.tgz",
"integrity": "sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==",
"version": "3.29.4",
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.29.4.tgz",
"integrity": "sha512-SPXpDT2cHEy+d26V1RXYMlVzXN42hotFAak1fzyWPi4o2dKXb61UqD4pzxDJHwk6gbv8vQ6EfErd+hYX0Qhzug==",
"license": "MIT"
},
"node_modules/pkg-types": {
+1 -1
View File
@@ -25,7 +25,7 @@
"os": "^0.1.2",
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
"pinyin-pro": "^3.29.3",
"pinyin-pro": "^3.29.4",
"tesseract.js": "^7.0.0",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
+32
View File
@@ -0,0 +1,32 @@
import request from "@/utils/request";
/**
* CRM 产品管理(产品台账/目录)接口。
*/
/* --------------------------------- 产品 --------------------------------- */
export function getProductList(params) {
return request({ url: "/backend/crm/product/list", method: "get", params });
}
export function getProductDetail(id) {
return request({ url: `/backend/crm/product/${id}`, method: "get" });
}
export function createProduct(data) {
return request({ url: "/backend/crm/product", method: "post", data });
}
export function updateProduct(id, data) {
return request({ url: `/backend/crm/product/${id}`, method: "put", data });
}
export function deleteProduct(id) {
return request({ url: `/backend/crm/product/${id}`, method: "delete" });
}
/** 项目生成/保存时,把项目产品清单写入产品管理 */
export function syncProjectProducts(data) {
return request({ url: "/backend/crm/product/sync-from-project", method: "post", data });
}
+25
View File
@@ -0,0 +1,25 @@
import request from "@/utils/request";
/**
* CRM 产品分类(用户自定义产品归类)接口。
*/
export function getProductCategoryList(params) {
return request({ url: "/backend/crm/product/category/list", method: "get", params });
}
export function getProductCategoryDetail(id) {
return request({ url: `/backend/crm/product/category/${id}`, method: "get" });
}
export function createProductCategory(data) {
return request({ url: "/backend/crm/product/category", method: "post", data });
}
export function updateProductCategory(id, data) {
return request({ url: `/backend/crm/product/category/${id}`, method: "put", data });
}
export function deleteProductCategory(id) {
return request({ url: `/backend/crm/product/category/${id}`, method: "delete" });
}
+19
View File
@@ -54,6 +54,13 @@ const staticMainChildren = [
component: () => import("@/views/apps/cms/solution/type/index.vue"),
meta: { requiresAuth: true, title: "解决方案分类", modulePath: "/apps/cms" }
},
// CRM 产品管理(产品台账/目录):项目生成时自动写入产品参数
{
path: "/apps/crm/product",
name: "CrmProduct",
component: () => import("@/views/apps/crm/product/index.vue"),
meta: { requiresAuth: true, title: "产品管理", modulePath: "/apps/crm" }
},
// 兼容旧路径:articles/* -> article/*
{
path: "/apps/cms/articles",
@@ -63,6 +70,18 @@ const staticMainChildren = [
path: "/apps/cms/articles/category",
redirect: "/apps/cms/article/type"
},
// CRM 产品分类(用户自定义产品归类):产品表单的分类下拉取自该表
{
path: "/apps/crm/product/cate",
name: "CrmProductCategory",
component: () => import("@/views/apps/crm/product/cate.vue"),
meta: { requiresAuth: true, title: "产品分类", modulePath: "/apps/crm" }
},
// 兼容带 .vue 后缀的菜单路径
{
path: "/apps/crm/product/cate.vue",
redirect: "/apps/crm/product/cate"
},
{
path: "/user/userProfile",
name: "userProfile",
@@ -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);
+22 -19
View File
@@ -3,7 +3,7 @@
<div class="page-header">
<div>
<h2>合同管理</h2>
<p>进度式创建合同,绑定项目或无头合同,管理甲乙丙丁各方与产品清单</p>
<p>进度式创建合同,绑定项目或无头合同</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
@@ -73,12 +73,19 @@
<div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border row-key="id">
<el-table-column label="状态" width="90" align="center" fixed>
<template #default="{ row }">
<el-tag :type="contractStatusTag(row.status)" size="small">
{{ contractStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip />
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip fixed>
<template #default="{ row }">
<span class="name-link" @click="openRow(row)">
{{ row.contract_name }}
<el-tag v-if="Number(row.status) === 1" size="small" type="warning" effect="light">草稿</el-tag>
<!-- <el-tag v-if="Number(row.status) === 1" size="small" type="warning" effect="light">草稿</el-tag> -->
</span>
</template>
</el-table-column>
@@ -95,12 +102,6 @@
{{ row.project_name || "无" }}
</template>
</el-table-column>
<el-table-column label="甲方" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ partyName(row, "party_a") }}</template>
</el-table-column>
<el-table-column label="乙方" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ partyName(row, "party_b") }}</template>
</el-table-column>
<el-table-column label="合同总金额" width="130" align="right">
<template #default="{ row }">
{{ formatMoney(row.summary?.total_amount ?? row.total_amount) }}
@@ -117,18 +118,8 @@
<el-table-column label="签订日期" width="110" align="center">
<template #default="{ row }">{{ formatDateOnly(row.sign_date) }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<el-table-column label="操作" width="190" align="center" class-name="op-col" fixed="right">
<template #default="{ row }">
<el-tag :type="contractStatusTag(row.status)" size="small">
{{ contractStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">
{{ Number(row.status) === 1 ? "继续填写" : "编辑" }}
</el-button>
<!-- 状态流转:草稿 / 已完成 / 履约中 / 执行异常 / 已作废 互切 -->
<el-dropdown trigger="click" @command="(cmd) => handleChangeStatus(row, cmd)">
<el-button link type="warning" size="small">
@@ -146,6 +137,9 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button link type="primary" size="small" @click="openEdit(row)">
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
@@ -368,4 +362,13 @@ async function handleDelete(row) {
}
}
}
/* 操作列:强制水平居中(多按钮换行也整体居中) */
:deep(.el-table .op-col .cell) {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 4px;
}
</style>
+39
View File
@@ -360,6 +360,45 @@ export const productCategoryText = (val) =>
PRODUCT_CATEGORY_MAP[normalize(val)] || normalize(val) || "-";
export const productCategoryTag = (val) => PRODUCT_CATEGORY_TAG[normalize(val)] || "info";
/* =====================================================================
* 产品管理(CRM 产品台账/目录)
* 分类复用合同产品分类:1硬件/2软件/3服务/4开发/5其他
* 状态:1=启用 0=停用
* ===================================================================== */
/** 产品状态:1=启用 0=停用 */
export const PRODUCT_STATUS_OPTIONS = [
{ label: "启用", value: 1 },
{ label: "停用", value: 0 },
];
/** 产品单位 */
export const PRODUCT_UNIT_OPTIONS = [
{ label: "套", value: "套" },
{ label: "台", value: "台" },
{ label: "个", value: "个" },
{ label: "件", value: "件" },
{ label: "批", value: "批" },
{ label: "次", value: "次" },
{ label: "人月", value: "人月" },
{ label: "人天", value: "人天" },
{ label: "年", value: "年" },
];
const PRODUCT_STATUS_MAP = { 1: "启用", 0: "停用" };
const PRODUCT_STATUS_TAG = { 1: "success", 0: "info" };
export const productStatusText = (val) => PRODUCT_STATUS_MAP[normalize(val)] || "-";
export const productStatusTag = (val) => PRODUCT_STATUS_TAG[normalize(val)] || "info";
/** 税率格式化:13 -> 13% */
export function formatPercent(val) {
if (val === undefined || val === null || val === "") return "-";
const n = Number(val);
if (isNaN(n)) return String(val);
return `${n}%`;
}
/** 合同分类选项(供 el-select 遍历) */
export const contractCategoryOptions = CONTRACT_CATEGORY_OPTIONS;
export const contractStatusOptions = CONTRACT_STATUS_OPTIONS;
+245
View File
@@ -0,0 +1,245 @@
<template>
<div class="crm-page">
<div class="page-header">
<div>
<h2>产品分类</h2>
<p>维护 CRM 产品的自定义归类,产品表单的分类下拉取自本表</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新增分类</el-button>
</div>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item>
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索分类名称 / 编码"
:prefix-icon="Search"
style="width: 240px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 120px">
<el-option v-for="i in PRODUCT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border row-key="id">
<el-table-column prop="name" label="分类名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="code" label="分类编码" width="140" show-overflow-tooltip />
<el-table-column prop="sort" label="排序" width="90" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="productStatusTag(row.status)" size="small">{{ productStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无产品分类" :image-size="80" /></template>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="fetchList"
/>
</div>
</div>
<el-dialog
:model-value="dialogVisible"
:title="isEdit ? '编辑分类' : '新增分类'"
width="520px"
:close-on-click-modal="false"
@update:model-value="handleClose"
@opened="handleOpened"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" label-position="right">
<el-form-item label="分类名称" prop="name">
<el-input v-model="form.name" placeholder="如:服务器类 / 软件许可" maxlength="100" />
</el-form-item>
<el-form-item label="分类编码" prop="code">
<el-input v-model="form.code" placeholder="选填,如:SERVER" maxlength="50" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" :controls="false" style="width: 140px" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
<el-option v-for="i in PRODUCT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import {
getProductCategoryList,
createProductCategory,
updateProductCategory,
deleteProductCategory,
} from "@/api/crmProductCategory";
import { PRODUCT_STATUS_OPTIONS, productStatusText, productStatusTag } from "../dict";
const loading = ref(false);
const tableData = ref([]);
const dialogVisible = ref(false);
const submitting = ref(false);
const isEdit = ref(false);
const currentId = ref(null);
const formRef = ref();
const defaultForm = () => ({
name: "",
code: "",
sort: 0,
status: 1,
remark: "",
});
const form = reactive(defaultForm());
const rules = {
name: [{ required: true, message: "请输入分类名称", trigger: "blur" }],
};
const filters = reactive({ keyword: "", status: "" });
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
onMounted(() => {
fetchList();
});
async function fetchList() {
loading.value = true;
try {
const res = await getProductCategoryList({
page: pagination.page,
pageSize: pagination.pageSize,
...filters,
});
tableData.value = res?.data?.list || [];
pagination.total = res?.data?.total || 0;
} catch (e) {
tableData.value = [];
pagination.total = 0;
ElMessage.error(e.message || "查询失败");
} finally {
loading.value = false;
}
}
function handleSearch() {
pagination.page = 1;
fetchList();
}
function resetFilters() {
filters.keyword = "";
filters.status = "";
handleSearch();
}
function openCreate() {
isEdit.value = false;
currentId.value = null;
Object.assign(form, defaultForm());
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
currentId.value = row.id;
Object.assign(form, defaultForm(), row);
form.status = Number(row.status);
if (isNaN(form.status)) form.status = 1;
form.sort = Number(row.sort) || 0;
dialogVisible.value = true;
}
function handleClose() {
dialogVisible.value = false;
}
function handleOpened() {
formRef.value?.clearValidate();
}
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch (e) {
return;
}
submitting.value = true;
try {
const payload = { ...form };
if (isEdit.value) {
await updateProductCategory(currentId.value, payload);
ElMessage.success("更新成功");
} else {
await createProductCategory(payload);
ElMessage.success("创建成功");
}
dialogVisible.value = false;
fetchList();
} catch (e) {
ElMessage.error(e.message || "操作失败");
} finally {
submitting.value = false;
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定删除产品分类「${row.name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
});
await deleteProductCategory(row.id);
ElMessage.success("删除成功");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
</script>
<style lang="less" scoped src="../styles/crm-page.less"></style>
@@ -0,0 +1,94 @@
<template>
<el-drawer
:model-value="visible"
:title="product?.product_name || '产品详情'"
size="520px"
direction="rtl"
@update:model-value="handleClose"
>
<div v-if="product" class="product-detail">
<el-descriptions :column="1" border>
<el-descriptions-item label="产品编号">{{ product.product_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="产品名称">{{ product.product_name }}</el-descriptions-item>
<el-descriptions-item label="产品分类">
<el-tag :type="categoryType(product.category)" size="small" effect="plain">
{{ categoryDisplay(product.category) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="规格型号">{{ product.spec || "-" }}</el-descriptions-item>
<el-descriptions-item label="单位">{{ product.unit || "-" }}</el-descriptions-item>
<el-descriptions-item label="销售单价">¥{{ formatMoney(product.price) }}</el-descriptions-item>
<el-descriptions-item label="成本单价">¥{{ formatMoney(product.cost_price) }}</el-descriptions-item>
<el-descriptions-item label="税率">{{ formatPercent(product.tax_rate) }}</el-descriptions-item>
<el-descriptions-item label="毛利">
¥{{ formatMoney((Number(product.price) || 0) - (Number(product.cost_price) || 0)) }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="productStatusTag(product.status)" size="small">{{ productStatusText(product.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="来源项目">{{ product.project_name || "手动创建" }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ product.remark || "-" }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(product.create_time) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(product.update_time) }}</el-descriptions-item>
</el-descriptions>
</div>
</el-drawer>
</template>
<script setup>
import { reactive, watch } from "vue";
import {
productCategoryText,
productCategoryTag,
productStatusText,
productStatusTag,
formatMoney,
formatPercent,
formatDateTime,
} from "../../dict";
import { getProductCategoryList } from "@/api/crmProductCategory";
const props = defineProps({
visible: { type: Boolean, default: false },
product: { type: Object, default: null },
});
const emit = defineEmits(["update:visible"]);
const categoryMap = reactive({});
/** 详情分类展示:优先管理分类名称;同步写入的产品带「金额归属」编码时回退 */
function categoryDisplay(val) {
if (!val) return "-";
if (categoryMap[val]) return val;
return productCategoryText(val);
}
function categoryType(val) {
if (!val || categoryMap[val]) return "primary";
return productCategoryTag(val);
}
watch(
() => props.visible,
(val) => {
if (val && Object.keys(categoryMap).length === 0) {
getProductCategoryList({ page: 1, pageSize: 200 })
.then((res) => {
(res?.data?.list || []).forEach((c) => {
categoryMap[c.name] = c.name;
});
})
.catch(() => {});
}
}
);
function handleClose() {
emit("update:visible", false);
}
</script>
<style lang="less" scoped>
.product-detail {
padding: 4px;
}
</style>
@@ -0,0 +1,189 @@
<template>
<el-dialog
:model-value="visible"
:title="isEdit ? '编辑产品' : '新增产品'"
width="720px"
:close-on-click-modal="false"
@update:model-value="handleClose"
@opened="handleOpened"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" label-position="right">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="产品名称" prop="product_name">
<el-input v-model="form.product_name" placeholder="请输入产品名称" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品编号" prop="product_no">
<el-input v-model="form.product_no" placeholder="如:P-2026-001" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品分类" prop="category">
<el-select
v-model="form.category"
filterable
allow-create
clearable
placeholder="请选择或输入分类"
style="width: 100%"
>
<el-option v-for="i in categoryOptions" :key="i.id" :label="i.name" :value="i.name" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位" prop="unit">
<el-select v-model="form.unit" filterable allow-create clearable placeholder="请选择" style="width: 100%">
<el-option v-for="i in PRODUCT_UNIT_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="规格型号" prop="spec">
<el-input v-model="form.spec" placeholder="如:型号 / 配置 / 功能模块" maxlength="255" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="销售单价" prop="price">
<el-input-number v-model="form.price" :min="0" :precision="2" :controls="false" style="width: 100%">
<template #prefix>¥</template>
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="成本单价" prop="cost_price">
<el-input-number v-model="form.cost_price" :min="0" :precision="2" :controls="false" style="width: 100%">
<template #prefix>¥</template>
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="税率(%)" prop="tax_rate">
<el-input-number v-model="form.tax_rate" :min="0" :max="100" :precision="2" :controls="false" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
<el-option v-for="i in PRODUCT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { createProduct, updateProduct } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import { PRODUCT_STATUS_OPTIONS, PRODUCT_UNIT_OPTIONS } from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
editData: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref();
const submitting = ref(false);
const isEdit = ref(false);
const internalId = ref(null);
const categoryOptions = ref([]);
const defaultForm = () => ({
product_name: "",
product_no: "",
category: "",
unit: "",
spec: "",
price: 0,
cost_price: 0,
tax_rate: 0,
status: 1,
remark: "",
});
const form = reactive(defaultForm());
const rules = {
product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
};
watch(
() => props.visible,
(val) => {
if (val) {
if (props.editData) {
isEdit.value = true;
internalId.value = props.editData.id;
Object.assign(form, defaultForm(), props.editData);
form.status = Number(props.editData.status);
if (isNaN(form.status)) form.status = 1;
} else {
isEdit.value = false;
internalId.value = null;
Object.assign(form, defaultForm());
}
fetchCategories();
}
}
);
/** 加载产品分类(用于分类下拉;取启用中的分类,允许新建时输入自定义分类) */
async function fetchCategories() {
try {
const res = await getProductCategoryList({ page: 1, pageSize: 200, status: 1 });
categoryOptions.value = res?.data?.list || [];
} catch (e) {
categoryOptions.value = [];
}
}
function handleClose() {
emit("update:visible", false);
}
function handleOpened() {
formRef.value?.clearValidate();
}
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch (e) {
return;
}
submitting.value = true;
try {
const payload = { ...form };
if (internalId.value) {
await updateProduct(internalId.value, payload);
ElMessage.success("更新成功");
} else {
await createProduct(payload);
ElMessage.success("创建成功");
}
emit("success");
handleClose();
} catch (e) {
ElMessage.error(e.message || "操作失败");
} finally {
submitting.value = false;
}
}
</script>
@@ -0,0 +1,392 @@
<template>
<div class="crm-page">
<div class="page-header">
<div>
<h2>产品管理</h2>
<p>维护可售产品台账(目录),项目生成时自动写入产品参数</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新增产品</el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<span class="stat-label">产品总数</span>
<span class="stat-value">{{ pagination.total }}</span>
</div>
<div class="stat-card">
<span class="stat-label">启用中</span>
<span class="stat-value success">{{ stats.enabled }}</span>
</div>
<div class="stat-card">
<span class="stat-label">已停用</span>
<span class="stat-value"> {{ stats.disabled }}</span>
</div>
<div class="stat-card">
<span class="stat-label">平均销售单价</span>
<span class="stat-value primary">¥{{ formatMoney(stats.avgPrice) }}</span>
</div>
</div>
<!-- 左分类 / 右数据 -->
<div class="page-body">
<!-- 左侧:产品分类 -->
<aside class="cate-panel">
<div class="cate-title">产品分类</div>
<ul class="cate-list">
<li :class="['cate-item', { active: activeCate === '' }]" @click="selectCate('')">
<span class="cate-name">全部产品</span>
</li>
<li
v-for="c in categoryOptions"
:key="c.id"
:class="['cate-item', { active: activeCate === c.name }]"
@click="selectCate(c.name)"
>
<span class="cate-name">{{ c.name }}</span>
</li>
</ul>
</aside>
<!-- 右侧:当前分类下的产品数据 -->
<section class="main-panel">
<div class="filter-bar">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item>
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索产品名称 / 编号 / 规格"
:prefix-icon="Search"
style="width: 260px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 120px">
<el-option v-for="i in PRODUCT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border row-key="id">
<el-table-column label="产品名称" min-width="180" show-overflow-tooltip fixed>
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.product_name }}</span>
</template>
</el-table-column>
<el-table-column prop="product_no" label="产品编号" width="130" show-overflow-tooltip />
<el-table-column label="分类" width="130" align="center">
<template #default="{ row }">
<el-tag :type="categoryType(row.category)" size="small" effect="plain">
{{ categoryDisplay(row.category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="spec" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column label="销售单价" width="120" align="right">
<template #default="{ row }">¥{{ formatMoney(row.price) }}</template>
</el-table-column>
<el-table-column label="成本单价" width="120" align="right">
<template #default="{ row }">¥{{ formatMoney(row.cost_price) }}</template>
</el-table-column>
<el-table-column label="税率" width="80" align="center">
<template #default="{ row }">{{ formatPercent(row.tax_rate) }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="productStatusTag(row.status)" size="small">{{ productStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="来源项目" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.project_name || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无产品" :image-size="80" /></template>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="fetchList"
/>
</div>
</div>
</section>
</div>
<ProductEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
<ProductDetail v-model:visible="detailVisible" :product="currentRow" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import { getProductList, deleteProduct } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import ProductEdit from "./components/edit.vue";
import ProductDetail from "./components/detail.vue";
import {
PRODUCT_STATUS_OPTIONS,
productCategoryText,
productCategoryTag,
productStatusText,
productStatusTag,
formatMoney,
formatPercent,
} from "../dict";
const loading = ref(false);
const tableData = ref([]);
const editVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
const categoryOptions = ref([]);
const categoryMap = reactive({});
/** 左侧当前选中的分类值:空=全部;自定义分类=分类名称;系统分类=编码 */
const activeCate = ref("");
/** 产品分类展示:优先显示管理分类名称;同步写入的产品带「金额归属」编码时回退显示 */
function categoryDisplay(val) {
if (!val) return "-";
if (categoryMap[val]) return val;
return productCategoryText(val);
}
function categoryType(val) {
if (!val || categoryMap[val]) return "primary";
return productCategoryTag(val);
}
async function fetchCategories() {
try {
// 与「产品分类」管理界面同源同序(后端按 sort, -id 排序,pageSize 上限 200)
const res = await getProductCategoryList({ page: 1, pageSize: 200 });
const list = res?.data?.list || [];
categoryOptions.value = list;
list.forEach((c) => {
categoryMap[c.name] = c.name;
});
} catch (e) {
categoryOptions.value = [];
}
}
const stats = reactive({ enabled: 0, disabled: 0, avgPrice: 0 });
// 分类筛选由左侧面板控制,这里只保留关键词与状态
const filters = reactive({ keyword: "", status: "" });
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
onMounted(() => {
fetchCategories();
fetchList();
});
async function fetchList() {
loading.value = true;
try {
const res = await getProductList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: filters.keyword,
status: filters.status,
category: activeCate.value,
});
const list = res?.data?.list || [];
tableData.value = list;
pagination.total = res?.data?.total || 0;
// 本地汇总统计(后端按当前过滤返回,统计基于当前列表即可)
let enabled = 0;
let disabled = 0;
let priceSum = 0;
list.forEach((p) => {
if (Number(p.status) === 1) enabled++;
else disabled++;
priceSum += Number(p.price) || 0;
});
stats.enabled = enabled;
stats.disabled = disabled;
stats.avgPrice = list.length ? priceSum / list.length : 0;
} catch (e) {
tableData.value = [];
pagination.total = 0;
ElMessage.error(e.message || "查询失败");
} finally {
loading.value = false;
}
}
/** 切换左侧分类:回到第一页并重新加载对应数据 */
function selectCate(val) {
if (activeCate.value === val) return;
activeCate.value = val;
handleSearch();
}
function handleSearch() {
pagination.page = 1;
fetchList();
}
function resetFilters() {
filters.keyword = "";
filters.status = "";
activeCate.value = "";
handleSearch();
}
function openCreate() {
currentRow.value = null;
editVisible.value = true;
}
function openEdit(row) {
currentRow.value = { ...row };
editVisible.value = true;
}
function openDetail(row) {
currentRow.value = { ...row };
detailVisible.value = true;
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定删除产品「${row.product_name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
});
await deleteProduct(row.id);
ElMessage.success("删除成功");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
</script>
<style lang="less" scoped src="../styles/crm-page.less"></style>
<style lang="less" scoped>
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 12px;
.stat-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 6px;
.stat-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
&.primary {
color: var(--el-color-primary);
}
&.success {
color: var(--el-color-success);
}
}
}
}
/* 左分类 + 右数据 */
.page-body {
display: flex;
align-items: flex-start;
gap: 12px;
}
.cate-panel {
width: 200px;
flex-shrink: 0;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
padding: 8px;
.cate-title {
padding: 8px 10px 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.cate-list {
list-style: none;
margin: 0;
padding: 0;
}
.cate-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
margin-bottom: 2px;
border-radius: 4px;
font-size: 13px;
color: var(--el-text-color-primary);
cursor: pointer;
user-select: none;
transition: background 0.2s, color 0.2s;
&:hover {
background: var(--el-fill-color-light);
}
&.active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 600;
}
.cate-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.main-panel {
flex: 1;
min-width: 0;
}
</style>
@@ -37,6 +37,16 @@
/>
</el-tab-pane>
<!-- 合同管理(一个项目可关联多份合同,横向 Tab 切换查看) -->
<el-tab-pane :label="`合同管理 (${counts.contract})`" name="contract">
<DetailContract
ref="contractRef"
:project-id="project.id"
:project-name="project.project_name || ''"
@count="counts.contract = $event"
/>
</el-tab-pane>
<!-- 附件(链路累积:线索 + 商机 + 项目) -->
<el-tab-pane :label="`附件 (${counts.attach})`" name="attach">
<DetailAttach
@@ -73,6 +83,7 @@ import { ref, reactive, watch } from "vue";
import DetailBasic from "./detail_basic.vue";
import DetailContact from "./detail_contact.vue";
import DetailFollow from "./detail_follow.vue";
import DetailContract from "./detail_contract.vue";
import DetailAttach from "./detail_attach.vue";
import DetailDocs from "./detail_docs.vue";
import DetailLog from "./detail_log.vue";
@@ -87,10 +98,11 @@ const emit = defineEmits(["update:visible", "refresh"]);
const RELATED_TYPE = 3;
const activeTab = ref("basic");
const counts = reactive({ contact: 0, follow: 0, attach: 0, docs: 0 });
const counts = reactive({ contact: 0, follow: 0, contract: 0, attach: 0, docs: 0 });
const contactRef = ref();
const followRef = ref();
const contractRef = ref();
const attachRef = ref();
const docsRef = ref();
const logRef = ref();
@@ -102,6 +114,7 @@ watch(
activeTab.value = "basic";
counts.contact = 0;
counts.follow = 0;
counts.contract = 0;
counts.attach = 0;
counts.docs = 0;
}
@@ -117,6 +130,7 @@ function loadAll() {
if (!props.project?.id) return;
contactRef.value?.reload();
followRef.value?.reload();
contractRef.value?.reload();
attachRef.value?.reload();
docsRef.value?.reload();
logRef.value?.reload();
@@ -0,0 +1,364 @@
<template>
<div class="detail-contract">
<div class="tab-toolbar">
<el-button type="primary" size="small" :icon="Plus" @click="handleCreate">新增合同</el-button>
<!-- <span class="tip">一个项目可关联多份合同,点击上方横向 Tab 切换查看</span> -->
</div>
<div v-loading="loading">
<!-- 多个合同:横向 Tab 逐个展示合同内容 -->
<el-tabs v-if="list.length" v-model="activeId" tab-position="top" class="contract-tabs">
<el-tab-pane
v-for="c in list"
:key="c.id"
:name="String(c.id)"
>
<template #label>
<span class="tab-label">
<span class="tab-name">{{ c.contract_name }}</span>
<el-tag :type="contractStatusTag(c.status)" size="small" effect="plain">
{{ contractStatusText(c.status) }}
</el-tag>
</span>
</template>
<div class="contract-pane">
<div class="pane-head">
<div class="pane-title">
<span class="name">{{ c.contract_name }}</span>
<el-tag :type="contractStatusTag(c.status)" size="small">
{{ contractStatusText(c.status) }}
</el-tag>
<el-tag type="primary" size="small" effect="plain">
{{ ourRoleText(c.our_role) }}
</el-tag>
</div>
<div class="pane-actions">
<el-button link type="primary" size="small" @click="handleEdit(c)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(c)">删除</el-button>
</div>
</div>
<div class="pane-sub">
{{ c.contract_no || "-" }} · {{ contractCategoryText(c.contract_category) }}
</div>
<el-descriptions :column="2" border style="margin-top: 12px">
<el-descriptions-item label="签订日期">{{ formatDateOnly(c.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ c.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(c.effective_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(c.expire_date) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ c.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">各方签约主体</el-divider>
<el-table :data="c.parties || []" border size="small">
<el-table-column label="角色" width="80" align="center">
<template #default="{ row }">{{ partyLabel(row.role) }}</template>
</el-table-column>
<el-table-column label="签约主体" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.ref_name || "-" }}</template>
</el-table-column>
<el-table-column label="来源" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="Number(row.ref_type) === 1" size="small" type="success" effect="plain">客户</el-tag>
<el-tag v-else-if="Number(row.ref_type) === 2" size="small" type="warning" effect="plain">供应商</el-tag>
<span v-else>本公司</span>
</template>
</el-table-column>
<el-table-column label="签约人" width="100">
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
</el-table-column>
<el-table-column label="联系电话" width="120">
<template #default="{ row }">{{ row.signer_phone || "-" }}</template>
</el-table-column>
</el-table>
<el-divider content-position="left">产品清单</el-divider>
<el-table :data="c.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="类别" width="100" align="center">
<template #default="{ row }">
<el-tag :type="productCategoryTag(row.category)" size="small" effect="plain">
{{ productCategoryText(row.category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="60" align="center" />
<el-table-column label="数量" width="80" align="right">
<template #default="{ row }">{{ row.quantity ?? "-" }}</template>
</el-table-column>
<el-table-column label="单价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.price) }}</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>
</el-table-column>
<el-table-column label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
<template #empty><el-empty description="暂无产品" :image-size="60" /></template>
</el-table>
<el-divider content-position="left">金额汇总</el-divider>
<div class="summary-grid">
<div class="summary-card">
<span class="label">合同总金额</span>
<span class="value primary">¥{{ formatMoney(summaryOf(c).total_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summaryOf(c).total_cost) }}</span>
</div>
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summaryOf(c).total_profit) }}</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
<span class="value warning">¥{{ formatMoney(summaryOf(c).hardware_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">软件部分金额</span>
<span class="value primary">¥{{ formatMoney(summaryOf(c).software_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">其他部分金额</span>
<span class="value">¥{{ formatMoney(summaryOf(c).other_amount) }}</span>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<el-empty v-else-if="!loading" description="暂无合同" :image-size="60">
<el-button type="primary" size="small" :icon="Plus" @click="handleCreate">新增合同</el-button>
</el-empty>
</div>
<!-- 进度式创建 / 编辑向导(新建时预设当前项目) -->
<ContractCreate
v-model:visible="createVisible"
:edit-data="editRow"
:init-step="initStep"
:preset-project="presetProject"
@success="reload"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { getContractList, deleteContract } from "@/api/crmContract";
import ContractCreate from "../../contract/components/create.vue";
import { PARTY_ROLES, buildSummary } from "../../contract/components/utils";
import {
formatMoney,
formatDateOnly,
ourRoleText,
contractCategoryText,
contractStatusText,
contractStatusTag,
productCategoryText,
productCategoryTag,
} from "../../dict";
const props = defineProps({
// 关联项目ID
projectId: { type: [Number, String], default: null },
// 关联项目名称(用于新建合同时预设)
projectName: { type: String, default: "" },
});
const emit = defineEmits(["count"]);
const loading = ref(false);
const list = ref([]);
const activeId = ref("");
const createVisible = ref(false);
const editRow = ref(null);
const initStep = ref(1);
/** 新建合同时预设的关联项目 */
const presetProject = computed(() =>
props.projectId ? { id: props.projectId, project_name: props.projectName } : null
);
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
/** 金额汇总:优先后端 summary,缺失时按产品清单前端重算 */
function summaryOf(c) {
const s = c?.summary;
if (s && Object.keys(s).length) return s;
return buildSummary(c?.products || []);
}
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
if (!props.projectId) {
list.value = [];
emit("count", 0);
return;
}
loading.value = true;
try {
const res = await getContractList({ project_id: props.projectId, page: 1, pageSize: 100 });
list.value = res?.data?.list || [];
} catch (e) {
list.value = [];
} finally {
loading.value = false;
}
// 保持当前选中合同有效,失效时回落到第一份
if (!list.value.some((c) => String(c.id) === String(activeId.value))) {
activeId.value = list.value.length ? String(list.value[0].id) : "";
}
emit("count", list.value.length);
}
function handleCreate() {
editRow.value = null;
initStep.value = 1;
createVisible.value = true;
}
function handleEdit(c) {
editRow.value = { ...c };
initStep.value = Number(c.step) === 2 ? 2 : 1;
createVisible.value = true;
}
async function handleDelete(c) {
try {
await ElMessageBox.confirm(`确定删除合同「${c.contract_name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
});
await deleteContract(c.id);
ElMessage.success("删除成功");
reload();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
defineExpose({ reload });
</script>
<style lang="less" scoped>
.tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.tab-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.contract-tabs {
:deep(.el-tabs__header) {
margin-right: 0;
}
:deep(.el-tabs__item) {
min-width: auto;
justify-content: center;
text-align: center;
padding: 0 14px;
height: 40px;
line-height: 40px;
}
:deep(.el-tabs__content) {
padding-left: 0;
overflow: visible;
}
}
.tab-label {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 220px;
.tab-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.contract-pane {
.pane-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.pane-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
.name {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.pane-actions {
flex-shrink: 0;
}
.pane-sub {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
.summary-card {
padding: 10px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
display: flex;
flex-direction: column;
gap: 4px;
.label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.value {
font-size: 16px;
font-weight: 700;
&.primary {
color: var(--el-color-primary);
}
&.success {
color: var(--el-color-success);
}
&.warning {
color: var(--el-color-warning);
}
}
}
}
</style>
@@ -2,7 +2,7 @@
<el-dialog
:model-value="visible"
:title="isEdit ? '编辑项目' : '新增项目'"
width="760px"
width="960px"
:close-on-click-modal="false"
@update:model-value="handleClose"
@opened="handleOpened"
@@ -98,6 +98,13 @@
</el-col>
</el-row>
</el-form>
<!-- <el-divider content-position="left">
产品清单
<span class="tip">保存项目时,产品参数将自动写入「产品管理」</span>
</el-divider>
<ProductList :products="form.products" /> -->
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
@@ -109,10 +116,12 @@
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { createProject, updateProject } from "@/api/crmPipeline";
import { getProductList } from "@/api/crmProduct";
import { getCrmCustomerList } from "@/api/crm";
import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
import { PROJECT_STATUS_OPTIONS } from "../../dict";
import ProductList from "../../contract/components/ProductList.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -146,10 +155,58 @@ const defaultForm = () => ({
contact_phone: "",
address: "",
remark: "",
products: [],
});
const form = reactive(defaultForm());
/** 解析项目产品清单:兼容 JSON 字符串 / 数组 / 空,并补全行内 __key(el-table 的 row-key) */
function parseProducts(val) {
let arr = [];
if (Array.isArray(val)) {
arr = val;
} else if (typeof val === "string" && val.trim()) {
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) arr = parsed;
} catch {
arr = [];
}
}
// 缺失 __key 时多行会被当作同一行,导致回显不出来
return arr.map((r, i) => ({
...r,
__key: r && r.__key ? r.__key : `row_${i}_${Date.now()}`,
}));
}
/**
* 兜底回显:存量项目 / 商机转化项目的 products 可能从未写入(null 或空串),
* 此时从「产品管理」回查归属本项目的产品,保证编辑时能看到产品清单。
*/
async function loadProjectProducts(projectId) {
try {
// 后端 pageSize 上限为 100
const res = await getProductList({ project_id: projectId, page: 1, pageSize: 100 });
const list = res?.data?.list || [];
form.products = list.map((p, i) => ({
__key: `row_p${p.id}_${i}`,
name: p.product_name || "",
product_id: p.id,
category: p.category || "",
spec: p.spec || "",
unit: p.unit || "",
quantity: 1,
price: Number(p.price) || 0,
cost_price: Number(p.cost_price) || 0,
tax_rate: Number(p.tax_rate) || 0,
remark: p.remark || "",
}));
} catch (e) {
form.products = [];
}
}
const rules = {
project_name: [{ required: true, message: "请输入项目名称", trigger: "blur" }],
};
@@ -163,6 +220,15 @@ watch(
internalId.value = props.editData.id;
Object.assign(form, defaultForm(), props.editData);
form.status = Number(props.editData.status) || 1;
// 解析项目已关联的产品清单(后端返回 JSON 字符串)
const rawProducts = props.editData.products;
form.products = parseProducts(rawProducts);
// 从未写入过产品清单(null / 空串,而非 "[]")时,从产品管理回查兜底回显
const neverSet =
rawProducts === null || rawProducts === undefined || String(rawProducts).trim() === "";
if (neverSet && form.products.length === 0 && props.editData.id) {
loadProjectProducts(props.editData.id);
}
} else {
isEdit.value = false;
internalId.value = null;
@@ -220,7 +286,7 @@ async function handleSubmit() {
if (owner) form.owner_user_name = owner.name;
submitting.value = true;
try {
const payload = { ...form, amount: Number(form.amount) || 0 };
const payload = { ...form, amount: Number(form.amount) || 0, products: JSON.stringify(form.products || []) };
if (internalId.value) {
await updateProject(internalId.value, payload);
ElMessage.success("更新成功");
@@ -237,3 +303,11 @@ async function handleSubmit() {
}
}
</script>
<style lang="less" scoped>
.tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: 8px;
}
</style>
+25 -6
View File
@@ -81,6 +81,7 @@ func (c *BackendCrmContractController) List() {
ourRole := strings.TrimSpace(c.GetString("our_role"))
category := strings.TrimSpace(c.GetString("contract_category"))
projectType := strings.TrimSpace(c.GetString("project_type"))
projectID := strings.TrimSpace(c.GetString("project_id"))
status := strings.TrimSpace(c.GetString("status"))
tenantID := pipelineTenantID(claims)
@@ -110,6 +111,12 @@ func (c *BackendCrmContractController) List() {
case "headless": // 无头合同
cond = cond.And("project_id__isnull", true)
}
// 按项目精确筛选(项目详情「合同管理」Tab:一个项目可关联多份合同)
if projectID != "" {
if pid, err := strconv.ParseUint(projectID, 10, 64); err == nil && pid > 0 {
cond = cond.And("project_id", pid)
}
}
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
total, _ := qs.Count()
@@ -244,6 +251,10 @@ func (c *BackendCrmContractController) Create() {
return
}
row.Products = products
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
row.Products = string(synced)
}
applyContractAmounts(&row)
if _, err := models.Orm.Insert(&row); err != nil {
@@ -336,6 +347,10 @@ func (c *BackendCrmContractController) Update() {
return
}
row.Products = products
// 合同产品清单同步到产品管理(新增产品自动建档、成本单价取产品管理)
if synced, serr := SyncContractProducts(tenantID, pipelineUID(claims), json.RawMessage(products)); serr == nil {
row.Products = string(synced)
}
}
applyContractAmounts(&row)
if p.Status != 0 {
@@ -512,8 +527,11 @@ func normalizeContractJSON(raw json.RawMessage) (string, error) {
}
// applyContractAmounts 按产品清单重算各部分金额(与前端 ProductList 汇总口径一致):
// 硬件部分=Σ硬件小计;软件部分=Σ软件小计;其他部分=Σ服务/开发/其他小计;
// 合同总金额=硬件+软件+其他;产品总成本=Σ(数量×成本单价);合同总利润=总金额-总成本。
// 每行含税金额=数量×单价×(1+税率/100);
// 硬件部分=Σ硬件含税小计;软件部分=Σ软件含税小计;其他部分=Σ服务/开发/其他含税小计;
// 合同总金额(含税)=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本通常不含税,原值汇总);
// 合同总利润=总金额-总成本。
// 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他。
func applyContractAmounts(row *models.TenantCrmContract) {
var items []map[string]interface{}
if strings.TrimSpace(row.Products) != "" {
@@ -524,13 +542,14 @@ func applyContractAmounts(row *models.TenantCrmContract) {
qty := toFloat64(item["quantity"])
price := toFloat64(item["price"])
costPrice := toFloat64(item["cost_price"])
amount := round2(qty * price)
taxRate := toFloat64(item["tax_rate"])
amount := round2(qty * price * (1 + taxRate/100)) // 含税金额
cost = round2(cost + round2(qty*costPrice))
cat := fmt.Sprintf("%v", item["category"])
switch cat {
case "1":
switch {
case cat == "1" || strings.Contains(cat, "硬件"):
hardware = round2(hardware + amount)
case "2":
case cat == "2" || strings.Contains(cat, "软件"):
software = round2(software + amount)
default:
other = round2(other + amount)
@@ -83,6 +83,10 @@ func parsePipelineDateTime(s string) *time.Time {
}
layouts := []string{
"2006-01-02 15:04:05",
// 带时区偏移的 RFC3339:前端编辑时回显的是后端 JSON 序列化的 time.Time
// (如 2026-09-12T00:00:00+08:00),缺此布局会解析失败导致日期被写空。
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
+431
View File
@@ -0,0 +1,431 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendCrmProductController CRM 产品管理(产品台账/目录)
type BackendCrmProductController struct {
beego.Controller
}
type crmProductPayload struct {
ProductNo string `json:"product_no"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Unit string `json:"unit"`
Spec string `json:"spec"`
Price float64 `json:"price"`
CostPrice float64 `json:"cost_price"`
TaxRate float64 `json:"tax_rate"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
// crmProductItem 项目产品清单中的单行(也用于项目生成时写入产品管理)。
type crmProductItem struct {
ProductNo string `json:"product_no"`
Name string `json:"name"`
Category string `json:"category"`
Spec string `json:"spec"`
Unit string `json:"unit"`
Quantity interface{} `json:"quantity"`
Price float64 `json:"price"`
CostPrice float64 `json:"cost_price"`
TaxRate float64 `json:"tax_rate"`
Remark string `json:"remark"`
}
// List GET /backend/crm/product/list
func (c *BackendCrmProductController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
keyword := strings.TrimSpace(c.GetString("keyword"))
category := strings.TrimSpace(c.GetString("category"))
status := strings.TrimSpace(c.GetString("status"))
projectID, _ := c.GetUint64("project_id")
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if keyword != "" {
kw := orm.NewCondition().
Or("product_name__contains", keyword).
Or("product_no__contains", keyword).
Or("spec__contains", keyword)
cond = cond.AndCond(kw)
}
if category != "" {
cond = cond.And("category", category)
}
if status != "" {
cond = cond.And("status", status)
}
if projectID > 0 {
cond = cond.And("project_id", projectID)
}
qs := models.Orm.QueryTable(new(models.TenantCrmProduct)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmProduct
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize,
})
}
// Detail GET /backend/crm/product/:id
func (c *BackendCrmProductController) Detail() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var p models.TenantCrmProduct
if err := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
Filter("delete_time__isnull", true).One(&p); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品未找到")
return
}
pipelineOk(&c.Controller, p)
}
// Create POST /backend/crm/product
func (c *BackendCrmProductController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p crmProductPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if strings.TrimSpace(p.ProductName) == "" {
pipelineErr(&c.Controller, 400, 400, "产品名称不能为空")
return
}
status := p.Status
if status != 0 && status != 1 {
status = 1
}
now := time.Now()
row := models.TenantCrmProduct{
TenantID: pipelineTenantID(claims),
ProductNo: strings.TrimSpace(p.ProductNo),
ProductName: strings.TrimSpace(p.ProductName),
Category: strings.TrimSpace(p.Category),
Unit: strings.TrimSpace(p.Unit),
Spec: strings.TrimSpace(p.Spec),
Price: p.Price,
CostPrice: p.CostPrice,
TaxRate: p.TaxRate,
Status: status,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Update PUT /backend/crm/product/:id
func (c *BackendCrmProductController) Update() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p crmProductPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if strings.TrimSpace(p.ProductName) == "" {
pipelineErr(&c.Controller, 400, 400, "产品名称不能为空")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmProduct
if err := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品未找到")
return
}
status := p.Status
if status != 0 && status != 1 {
status = row.Status
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(orm.Params{
"product_no": strings.TrimSpace(p.ProductNo),
"product_name": strings.TrimSpace(p.ProductName),
"category": strings.TrimSpace(p.Category),
"unit": strings.TrimSpace(p.Unit),
"spec": strings.TrimSpace(p.Spec),
"price": p.Price,
"cost_price": p.CostPrice,
"tax_rate": p.TaxRate,
"status": status,
"remark": p.Remark,
"update_time": now,
})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Delete DELETE /backend/crm/product/:id
func (c *BackendCrmProductController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmProduct
if err := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品未找到")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(orm.Params{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
pipelineOk(&c.Controller, nil)
}
// SyncFromProject POST /backend/crm/product/sync-from-project
// 项目生成(或保存)时,把项目产品清单里的产品参数写入产品管理(按 名称+分类 去重 upsert)。
func (c *BackendCrmProductController) SyncFromProject() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var body struct {
ProjectID uint64 `json:"project_id"`
ProjectName string `json:"project_name"`
Products []crmProductItem `json:"products"`
}
if err := json.Unmarshal(raw, &body); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if body.ProjectID == 0 {
pipelineErr(&c.Controller, 400, 400, "缺少项目ID")
return
}
created, updated, err := SyncProjectProducts(pipelineTenantID(claims), body.ProjectID, strings.TrimSpace(body.ProjectName), body.Products)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "同步失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"created": created, "updated": updated})
}
// SyncProjectProducts 把项目产品清单写入产品管理:按 产品名称+分类 在同一租户下去重。
// - 已存在:刷新单价/成本/税率/规格/单位/来源项目/备注;
// - 不存在:新建产品档案,登记来源项目。
// 返回新建数、更新数。
func SyncProjectProducts(tenantID string, projectID uint64, projectName string, items []crmProductItem) (created int, updated int, err error) {
if len(items) == 0 {
return 0, 0, nil
}
now := time.Now()
for _, it := range items {
name := strings.TrimSpace(it.Name)
if name == "" {
continue
}
cat := strings.TrimSpace(it.Category)
var exist models.TenantCrmProduct
e := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("tenant_id", tenantID).
Filter("product_name", name).
Filter("category", cat).
Filter("delete_time__isnull", true).
OrderBy("-id").Limit(1).One(&exist)
if e == nil && exist.ID > 0 {
// 已存在:更新参数
_, uerr := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", exist.ID).
Update(orm.Params{
"product_no": strings.TrimSpace(it.ProductNo),
"spec": strings.TrimSpace(it.Spec),
"unit": strings.TrimSpace(it.Unit),
"price": it.Price,
"cost_price": it.CostPrice,
"tax_rate": it.TaxRate,
"project_id": projectID,
"project_name": projectName,
"remark": it.Remark,
"update_time": now,
})
if uerr != nil {
err = uerr
return
}
updated++
continue
}
// 不存在:新建
row := models.TenantCrmProduct{
TenantID: tenantID,
ProductNo: strings.TrimSpace(it.ProductNo),
ProductName: name,
Category: cat,
Unit: strings.TrimSpace(it.Unit),
Spec: strings.TrimSpace(it.Spec),
Price: it.Price,
CostPrice: it.CostPrice,
TaxRate: it.TaxRate,
Status: 1,
ProjectID: &projectID,
ProjectName: projectName,
Remark: it.Remark,
CreateUserID: "",
CreateTime: now,
UpdateTime: now,
}
if _, ierr := models.Orm.Insert(&row); ierr != nil {
err = ierr
return
}
created++
}
return created, updated, nil
}
// SyncContractProducts 把合同产品清单同步到产品管理(供合同保存时调用):
// - 已关联 product_id 或名称命中现有产品:回填 product_id,并将成本单价强制取产品管理
// (成本追溯产品管理;销售单价不回写,允许合同溢价);
// - 名称未命中(产品管理中不存在):新建产品档案,销售单价/成本单价/规格/单位/分类/税率取自合同行。
//
// 返回(可能已回填 product_id 与成本单价)的清单 JSON,供合同落库。
func SyncContractProducts(tenantID, uid 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
}
if err := json.Unmarshal(raw, &items); err != nil {
return raw, err
}
now := time.Now()
for _, it := range items {
name := strings.TrimSpace(fmt.Sprintf("%v", it["name"]))
if name == "" {
continue
}
// 已关联产品:成本单价强制取产品管理,不回写销售单价
if pid := toUint64(it["product_id"]); pid > 0 {
var p models.TenantCrmProduct
if e := models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", pid).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&p); e == nil && p.ID > 0 {
it["product_id"] = p.ID
it["cost_price"] = p.CostPrice
}
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 {
it["product_id"] = exist.ID
it["cost_price"] = exist.CostPrice
continue
}
// 未命中:新建产品档案(成本/单价等取自合同行)
row := models.TenantCrmProduct{
TenantID: 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"])),
Price: toFloat64(it["price"]),
CostPrice: toFloat64(it["cost_price"]),
TaxRate: toFloat64(it["tax_rate"]),
Status: 1,
Remark: strings.TrimSpace(fmt.Sprintf("%v", it["remark"])),
CreateUserID: uid,
CreateTime: now,
UpdateTime: now,
}
id, ierr := models.Orm.Insert(&row)
if ierr != nil {
return raw, ierr
}
it["product_id"] = uint64(id)
}
out, err := json.Marshal(items)
if err != nil {
return raw, err
}
return json.RawMessage(out), nil
}
// toUint64 转为 uint64(依赖同包 toInt64)。
func toUint64(v interface{}) uint64 {
return uint64(toInt64(v))
}
@@ -0,0 +1,223 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendCrmProductCategoryController CRM 产品分类(用户自定义产品归类)
type BackendCrmProductCategoryController struct {
beego.Controller
}
type crmProductCategoryPayload struct {
Name string `json:"name"`
Code string `json:"code"`
Sort int `json:"sort"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
// List GET /backend/crm/product/category/list
func (c *BackendCrmProductCategoryController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 20
}
keyword := strings.TrimSpace(c.GetString("keyword"))
status := strings.TrimSpace(c.GetString("status"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if keyword != "" {
kw := orm.NewCondition().
Or("name__contains", keyword).
Or("code__contains", keyword)
cond = cond.AndCond(kw)
}
if status != "" {
cond = cond.And("status", status)
}
qs := models.Orm.QueryTable(new(models.TenantCrmProductCategory)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmProductCategory
if total > 0 {
_, _ = qs.OrderBy("sort", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize,
})
}
// Detail GET /backend/crm/product/category/:id
func (c *BackendCrmProductCategoryController) Detail() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var row models.TenantCrmProductCategory
if err := models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品分类未找到")
return
}
pipelineOk(&c.Controller, row)
}
// Create POST /backend/crm/product/category
func (c *BackendCrmProductCategoryController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p crmProductCategoryPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
name := strings.TrimSpace(p.Name)
if name == "" {
pipelineErr(&c.Controller, 400, 400, "分类名称不能为空")
return
}
status := p.Status
if status != 0 && status != 1 {
status = 1
}
now := time.Now()
row := models.TenantCrmProductCategory{
TenantID: pipelineTenantID(claims),
Name: name,
Code: strings.TrimSpace(p.Code),
Sort: p.Sort,
Status: status,
Remark: p.Remark,
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
if strings.Contains(err.Error(), "uk_tenant_name") {
pipelineErr(&c.Controller, 400, 400, "分类名称已存在")
return
}
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Update PUT /backend/crm/product/category/:id
func (c *BackendCrmProductCategoryController) Update() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p crmProductCategoryPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
name := strings.TrimSpace(p.Name)
if name == "" {
pipelineErr(&c.Controller, 400, 400, "分类名称不能为空")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmProductCategory
if err := models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品分类未找到")
return
}
status := p.Status
if status != 0 && status != 1 {
status = row.Status
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(orm.Params{
"name": name,
"code": strings.TrimSpace(p.Code),
"sort": p.Sort,
"status": status,
"remark": p.Remark,
"update_time": now,
})
if err != nil {
if strings.Contains(err.Error(), "uk_tenant_name") {
pipelineErr(&c.Controller, 400, 400, "分类名称已存在")
return
}
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Delete DELETE /backend/crm/product/category/:id
func (c *BackendCrmProductCategoryController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmProductCategory
if err := models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "产品分类未找到")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProductCategory)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(orm.Params{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
pipelineOk(&c.Controller, nil)
}
+20
View File
@@ -38,6 +38,7 @@ type projectPayload struct {
ContactPhone string `json:"contact_phone"`
Address string `json:"address"`
Remark string `json:"remark"`
Products string `json:"products"` // 项目产品清单JSON(生成时写入产品管理)
}
// List GET /backend/crm/project/list
@@ -173,6 +174,7 @@ func (c *BackendCrmProjectController) Create() {
ContactPhone: strings.TrimSpace(p.ContactPhone),
Address: strings.TrimSpace(p.Address),
Remark: p.Remark,
Products: p.Products,
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
@@ -182,6 +184,16 @@ func (c *BackendCrmProjectController) Create() {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
// 项目生成时,把产品清单里的产品参数写入产品管理(按 名称+分类 去重)
if strings.TrimSpace(p.Products) != "" {
var items []crmProductItem
if json.Unmarshal([]byte(p.Products), &items) == nil {
if _, _, serr := SyncProjectProducts(tenantID, uint64(id), proj.ProjectName, items); serr != nil {
// 同步失败不影响项目创建,仅记录
_ = serr
}
}
}
// 项目建立后,同步在文档库「共享文档 / 项目文档」下建立同名文件夹(失败不影响项目创建)
if cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, uint64(id), proj.ProjectName, 0); err == nil {
proj.DocCategoryID = cid
@@ -253,12 +265,20 @@ func (c *BackendCrmProjectController) Update() {
proj.ContactPhone = strings.TrimSpace(p.ContactPhone)
proj.Address = strings.TrimSpace(p.Address)
proj.Remark = p.Remark
proj.Products = p.Products
proj.UpdateTime = time.Now()
if _, err := models.Orm.Update(&proj); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
// 项目保存时,把产品清单里的产品参数写入产品管理(按 名称+分类 去重)
if strings.TrimSpace(p.Products) != "" {
var items []crmProductItem
if json.Unmarshal([]byte(p.Products), &items) == nil {
_, _, _ = SyncProjectProducts(tenantID, proj.ID, proj.ProjectName, items)
}
}
// 项目改名时同步重命名文档库中的项目文件夹(失败静默,不影响项目更新)
if proj.DocCategoryID > 0 {
_ = services.RenameCrmProjectDocCategory(claims.TenantId, proj.DocCategoryID, proj.ProjectName)
@@ -0,0 +1,15 @@
-- 项目表新增「产品清单」字段:yz_backend_crm_project.products
-- 手动执行;可重复执行(列已存在时自动跳过,不再报 1060)。
-- 该字段用于保存项目生成产品时写入产品管理的产品清单JSON。
SET @dbname = DATABASE();
SET @tablename = 'yz_backend_crm_project';
SET @columnname = 'products';
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
'SELECT 1',
'ALTER TABLE yz_backend_crm_project ADD COLUMN products text COMMENT ''项目产品清单JSON(写入产品管理时使用)'''
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
+29
View File
@@ -0,0 +1,29 @@
-- CRM 产品管理表:yz_backend_crm_product
-- 手动执行(可重复执行);产品可由「项目生成」时自动写入,也可在「产品管理」中独立维护。
CREATE TABLE IF NOT EXISTS yz_backend_crm_product (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
product_no varchar(50) NOT NULL DEFAULT '' COMMENT '产品编号',
product_name varchar(100) NOT NULL COMMENT '产品名称',
category varchar(20) NOT NULL DEFAULT '' COMMENT '分类:1硬件/2软件/3服务/4开发/5其他',
unit varchar(20) NOT NULL DEFAULT '' COMMENT '单位',
spec varchar(255) NOT NULL DEFAULT '' COMMENT '规格型号',
price decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '销售单价',
cost_price decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '成本单价',
tax_rate decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '税率(%)',
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用',
project_id bigint(20) DEFAULT NULL COMMENT '来源项目ID(由项目生成时填入)',
project_name varchar(100) NOT NULL DEFAULT '' COMMENT '来源项目名称',
remark text COMMENT '备注',
create_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (id),
KEY idx_tenant_id (tenant_id),
KEY idx_product_name (tenant_id,product_name),
KEY idx_category (tenant_id,category),
KEY idx_project (tenant_id,project_id),
KEY idx_status (tenant_id,status),
KEY idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM产品管理表';
@@ -0,0 +1,19 @@
-- CRM 产品分类表:yz_backend_crm_product_category
-- 手动执行(可重复执行);建表后请在「产品管理-产品分类」中维护数据。
CREATE TABLE IF NOT EXISTS yz_backend_crm_product_category (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
name varchar(100) NOT NULL COMMENT '分类名称',
code varchar(50) NOT NULL DEFAULT '' COMMENT '分类编码(选填)',
sort int(11) NOT NULL DEFAULT '0' COMMENT '排序',
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用',
remark text COMMENT '备注',
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_name (tenant_id,name),
KEY idx_tenant_id (tenant_id),
KEY idx_status (tenant_id,status),
KEY idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM产品分类表';
+13
View File
@@ -77,6 +77,8 @@ func Init(_ string) {
new(TenantCrmClue),
new(TenantCrmBusiness),
new(TenantCrmProject),
new(TenantCrmProduct),
new(TenantCrmProductCategory),
new(TenantCrmFollow),
new(TenantCrmAttach),
new(TenantCrmEntityContact),
@@ -145,6 +147,7 @@ func Init(_ string) {
EnsureCrmProjectDocColumn()
EnsureCrmContractTable()
EnsureCrmContractOurRoleColumn()
EnsureCrmProjectProductsColumn()
}
// EnsureCrmContractOurRoleColumn 补齐合同表的我方角色字段(存量表已建时新增;
@@ -155,6 +158,14 @@ func EnsureCrmContractOurRoleColumn() {
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmProjectProductsColumn 补齐项目表的产品清单字段(存量表已建时新增;
// 表不存在或列已存在时忽略错误,可用 docs/sql/alter_backend_crm_project_products_column.sql 手动修复)。
func EnsureCrmProjectProductsColumn() {
sql := "ALTER TABLE " + new(TenantCrmProject).TableName() +
" ADD COLUMN products text COMMENT '项目产品清单JSON(写入产品管理时使用)'"
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmContractTable 合同表建表(CREATE TABLE IF NOT EXISTS,可重复执行;
// 建表失败(如表已存在但结构不一致)时静默忽略,可用 sql/yz_backend_crm_contract.sql 手动修复)。
func EnsureCrmContractTable() {
@@ -211,6 +222,8 @@ func EnsureCrmProjectDocColumn() {
}
// EnsureCrmCreateUserColumn 补齐客户/供应商的创建人字段(用于删除权限校验)。
// 同样兼容历史表名差异,表不存在或列已存在时忽略错误。
func EnsureCrmCreateUserColumn() {
+36
View File
@@ -0,0 +1,36 @@
package models
import "time"
// TenantCrmProduct CRM 产品管理(产品台账/目录): yz_backend_crm_product
//
// 说明:
// - 产品可独立维护,也可由「项目生成」时自动写入(project_id 记录来源项目);
// - category 复用合同产品分类:1硬件/2软件/3服务/4开发/5其他;
// - price 为销售单价,cost_price 为成本单价,tax_rate 为税率(%)。
type TenantCrmProduct struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
ProductNo string `orm:"column(product_no);size(50)" json:"product_no"` // 产品编号
ProductName string `orm:"column(product_name);size(100)" json:"product_name"` // 产品名称
Category string `orm:"column(category);size(20)" json:"category"` // 分类 1硬件/2软件/3服务/4开发/5其他
Unit string `orm:"column(unit);size(20)" json:"unit"` // 单位
Spec string `orm:"column(spec);size(255)" json:"spec"` // 规格型号
Price float64 `orm:"column(price);digits(14);decimals(2);default(0)" json:"price"` // 销售单价
CostPrice float64 `orm:"column(cost_price);digits(14);decimals(2);default(0)" json:"cost_price"` // 成本单价
TaxRate float64 `orm:"column(tax_rate);digits(6);decimals(2);default(0)" json:"tax_rate"` // 税率(%)
Status int8 `orm:"column(status);default(1)" json:"status"` // 1启用/0停用
ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 来源项目ID(由项目生成时填入)
ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 来源项目名称
Remark string `orm:"column(remark);type(text);null" json:"remark"`
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *TenantCrmProduct) TableName() string {
return "yz_backend_crm_product"
}
+27
View File
@@ -0,0 +1,27 @@
package models
import "time"
// TenantCrmProductCategory CRM 产品分类(用户自定义产品归类): yz_backend_crm_product_category
//
// 说明:
// - 与 CMS 产品分类类似,是 CRM 产品台账的产品归类(如:服务器类 / 软件许可 / 实施服务 等);
// - 产品表单的「产品分类」下拉取自该表;status 1启用/0停用。
type TenantCrmProductCategory struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
Name string `orm:"column(name);size(100)" json:"name"` // 分类名称
Code string `orm:"column(code);size(50)" json:"code"` // 分类编码(选填)
Sort int `orm:"column(sort);default(0)" json:"sort"` // 排序
Status int8 `orm:"column(status);default(1)" json:"status"` // 1启用/0停用
Remark string `orm:"column(remark);type(text);null" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *TenantCrmProductCategory) TableName() string {
return "yz_backend_crm_product_category"
}
+1
View File
@@ -27,6 +27,7 @@ type TenantCrmProject struct {
// DocCategoryID 项目在 OA 文档库的文件夹分类ID:
// 指向 yz_backend_oa_doc_category 中「共享文档 / 项目文档 / {项目名称}」这条记录(0 表示尚未建立)。
DocCategoryID uint64 `orm:"column(doc_category_id);default(0)" json:"doc_category_id"`
Products string `orm:"column(products);type(text);null" json:"products"` // 项目产品清单JSON(写入产品管理时使用)
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
+11
View File
@@ -401,6 +401,17 @@ func registerOrganizationRoutes(module string) {
beego.Router("/backend/crm/project", &controllers.BackendCrmProjectController{}, "post:Create")
beego.Router("/backend/crm/project/:id", &controllers.BackendCrmProjectController{}, "get:Detail;put:Update;delete:Delete")
// CRM产品管理(产品台账/目录;可由项目生成时写入)
beego.Router("/backend/crm/product/list", &controllers.BackendCrmProductController{}, "get:List")
beego.Router("/backend/crm/product", &controllers.BackendCrmProductController{}, "post:Create")
beego.Router("/backend/crm/product/:id", &controllers.BackendCrmProductController{}, "get:Detail;put:Update;delete:Delete")
beego.Router("/backend/crm/product/sync-from-project", &controllers.BackendCrmProductController{}, "post:SyncFromProject")
// CRM产品分类管理(用户自定义产品归类;产品表单的分类下拉取自该表)
beego.Router("/backend/crm/product/category/list", &controllers.BackendCrmProductCategoryController{}, "get:List")
beego.Router("/backend/crm/product/category", &controllers.BackendCrmProductCategoryController{}, "post:Create")
beego.Router("/backend/crm/product/category/:id", &controllers.BackendCrmProductCategoryController{}, "get:Detail;put:Update;delete:Delete")
// CRM项目文档(与OA文档库「共享文档/项目文档」绑定:列表/上传同步/删除同步/文件夹管理)
beego.Router("/backend/crm/project/:id/docs", &controllers.BackendCrmProjectController{}, "get:DocList")
beego.Router("/backend/crm/project/:id/doc-upload", &controllers.BackendCrmProjectController{}, "post:DocUpload")