批量优化产品和产品分类
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description:
|
||||
alwaysApply: true
|
||||
enabled: true
|
||||
updatedAt: 2026-09-11T14:51:38.120Z
|
||||
provider:
|
||||
---
|
||||
|
||||
调用前先执行codegraph的mcp
|
||||
Vendored
-1
@@ -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']
|
||||
|
||||
Generated
+4
-4
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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产品分类表';
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user