diff --git a/.codebuddy/rules/codegraph.mdc b/.codebuddy/rules/codegraph.mdc
new file mode 100644
index 0000000..43358c7
--- /dev/null
+++ b/.codebuddy/rules/codegraph.mdc
@@ -0,0 +1,9 @@
+---
+description:
+alwaysApply: true
+enabled: true
+updatedAt: 2026-09-11T14:51:38.120Z
+provider:
+---
+
+调用前先执行codegraph的mcp
\ No newline at end of file
diff --git a/backend/components.d.ts b/backend/components.d.ts
index dceff9a..2f3d0d6 100644
--- a/backend/components.d.ts
+++ b/backend/components.d.ts
@@ -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']
diff --git a/backend/package-lock.json b/backend/package-lock.json
index f053a40..6d58ff6 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -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": {
diff --git a/backend/package.json b/backend/package.json
index 67d047e..f1aaffd 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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",
diff --git a/backend/src/api/crmProduct.js b/backend/src/api/crmProduct.js
new file mode 100644
index 0000000..ec226ff
--- /dev/null
+++ b/backend/src/api/crmProduct.js
@@ -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 });
+}
diff --git a/backend/src/api/crmProductCategory.js b/backend/src/api/crmProductCategory.js
new file mode 100644
index 0000000..5b3d6cb
--- /dev/null
+++ b/backend/src/api/crmProductCategory.js
@@ -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" });
+}
diff --git a/backend/src/router/index.js b/backend/src/router/index.js
index 7d26fb4..f3d3a8a 100644
--- a/backend/src/router/index.js
+++ b/backend/src/router/index.js
@@ -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",
diff --git a/backend/src/views/apps/crm/contract/components/PartySelect.vue b/backend/src/views/apps/crm/contract/components/PartySelect.vue
index 7a49139..f3069dc 100644
--- a/backend/src/views/apps/crm/contract/components/PartySelect.vue
+++ b/backend/src/views/apps/crm/contract/components/PartySelect.vue
@@ -59,7 +59,7 @@
-
已关联:{{ party.ref_name }}
+
diff --git a/backend/src/views/apps/crm/contract/components/ProductList.vue b/backend/src/views/apps/crm/contract/components/ProductList.vue
index 13d6b84..ef8c228 100644
--- a/backend/src/views/apps/crm/contract/components/ProductList.vue
+++ b/backend/src/views/apps/crm/contract/components/ProductList.vue
@@ -2,29 +2,61 @@
-
+
-
-
-
-
-
-
+ onCategoryChange(row)"
+ >
+
+
+ { if (v) onProductFocus(row) }"
+ @change="(val) => onProductPick(row, val)"
+ >
+
+ {{ p.product_name }}
+ {{ p.spec || "—" }} · ¥{{ p.price }}
+
+
+
+
+
-
+
@@ -53,14 +85,29 @@
/>
-
+
+
+
+
+
+
+
+
@@ -84,7 +131,9 @@
添加产品
- 按类别自动归集:硬件 / 软件单列金额,服务、开发等计入其他
+
+ 先选产品类别,再在该分类下搜索/选择产品(自动带出规格、单位、成本、税率);分类内无对应名称可输入新增,提交时自动建档到产品管理
+
@@ -112,7 +161,10 @@
合同总利润
-
+
¥{{ formatMoney(summary.total_profit) }}
@@ -122,10 +174,12 @@
diff --git a/backend/src/views/apps/crm/dict.js b/backend/src/views/apps/crm/dict.js
index 6cb2c23..68b7dea 100644
--- a/backend/src/views/apps/crm/dict.js
+++ b/backend/src/views/apps/crm/dict.js
@@ -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;
diff --git a/backend/src/views/apps/crm/product/cate.vue b/backend/src/views/apps/crm/product/cate.vue
new file mode 100644
index 0000000..ff4065c
--- /dev/null
+++ b/backend/src/views/apps/crm/product/cate.vue
@@ -0,0 +1,245 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+
+
+
+
+
+
+
+ {{ productStatusText(row.status) }}
+
+
+
+ {{ row.remark || "-" }}
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/product/components/detail.vue b/backend/src/views/apps/crm/product/components/detail.vue
new file mode 100644
index 0000000..707d21e
--- /dev/null
+++ b/backend/src/views/apps/crm/product/components/detail.vue
@@ -0,0 +1,94 @@
+
+
+
+
+ {{ product.product_no || "-" }}
+ {{ product.product_name }}
+
+
+ {{ categoryDisplay(product.category) }}
+
+
+ {{ product.spec || "-" }}
+ {{ product.unit || "-" }}
+ ¥{{ formatMoney(product.price) }}
+ ¥{{ formatMoney(product.cost_price) }}
+ {{ formatPercent(product.tax_rate) }}
+
+ ¥{{ formatMoney((Number(product.price) || 0) - (Number(product.cost_price) || 0)) }}
+
+
+ {{ productStatusText(product.status) }}
+
+ {{ product.project_name || "手动创建" }}
+ {{ product.remark || "-" }}
+ {{ formatDateTime(product.create_time) }}
+ {{ formatDateTime(product.update_time) }}
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/product/components/edit.vue b/backend/src/views/apps/crm/product/components/edit.vue
new file mode 100644
index 0000000..73878f3
--- /dev/null
+++ b/backend/src/views/apps/crm/product/components/edit.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ¥
+
+
+
+
+
+
+ ¥
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/product/index.vue b/backend/src/views/apps/crm/product/index.vue
new file mode 100644
index 0000000..42114bc
--- /dev/null
+++ b/backend/src/views/apps/crm/product/index.vue
@@ -0,0 +1,392 @@
+
+
+
+
+
+
+
+ 产品总数
+ {{ pagination.total }}
+
+
+ 启用中
+ {{ stats.enabled }}
+
+
+ 已停用
+ {{ stats.disabled }}
+
+
+ 平均销售单价
+ ¥{{ formatMoney(stats.avgPrice) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+
+
+
+
+ {{ row.product_name }}
+
+
+
+
+
+
+ {{ categoryDisplay(row.category) }}
+
+
+
+
+
+
+ ¥{{ formatMoney(row.price) }}
+
+
+ ¥{{ formatMoney(row.cost_price) }}
+
+
+ {{ formatPercent(row.tax_rate) }}
+
+
+
+ {{ productStatusText(row.status) }}
+
+
+
+ {{ row.project_name || "-" }}
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/project/components/detail.vue b/backend/src/views/apps/crm/project/components/detail.vue
index 5c6e083..35b6139 100644
--- a/backend/src/views/apps/crm/project/components/detail.vue
+++ b/backend/src/views/apps/crm/project/components/detail.vue
@@ -37,6 +37,16 @@
/>
+
+
+
+
+
+
+
+ 新增合同
+
+
+
+
+
+
+
+
+
+ {{ c.contract_name }}
+
+ {{ contractStatusText(c.status) }}
+
+
+
+
+
+
+
+ {{ c.contract_name }}
+
+ {{ contractStatusText(c.status) }}
+
+
+ {{ ourRoleText(c.our_role) }}
+
+
+
+ 编辑
+ 删除
+
+
+
+ {{ c.contract_no || "-" }} · {{ contractCategoryText(c.contract_category) }}
+
+
+
+ {{ formatDateOnly(c.sign_date) }}
+ {{ c.owner_user_name || "-" }}
+ {{ formatDateOnly(c.effective_date) }}
+ {{ formatDateOnly(c.expire_date) }}
+ {{ c.remark || "-" }}
+
+
+
各方签约主体
+
+
+ {{ partyLabel(row.role) }}
+
+
+ {{ row.ref_name || "-" }}
+
+
+
+ 客户
+ 供应商
+ 本公司
+
+
+
+ {{ row.signer_name || "-" }}
+
+
+ {{ row.signer_phone || "-" }}
+
+
+
+
产品清单
+
+
+
+
+
+
+ {{ productCategoryText(row.category) }}
+
+
+
+
+
+ {{ row.quantity ?? "-" }}
+
+
+ {{ formatMoney(row.price) }}
+
+
+
+ {{ formatMoney((Number(row.quantity) || 0) * (Number(row.price) || 0)) }}
+
+
+
+ {{ row.remark || "-" }}
+
+
+
+
+
金额汇总
+
+
+ 合同总金额
+ ¥{{ formatMoney(summaryOf(c).total_amount) }}
+
+
+ 产品总成本
+ ¥{{ formatMoney(summaryOf(c).total_cost) }}
+
+
+ 合同总利润
+ ¥{{ formatMoney(summaryOf(c).total_profit) }}
+
+
+ 硬件部分金额
+ ¥{{ formatMoney(summaryOf(c).hardware_amount) }}
+
+
+ 软件部分金额
+ ¥{{ formatMoney(summaryOf(c).software_amount) }}
+
+
+ 其他部分金额
+ ¥{{ formatMoney(summaryOf(c).other_amount) }}
+
+
+
+
+
+
+
+ 新增合同
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/project/components/edit.vue b/backend/src/views/apps/crm/project/components/edit.vue
index 4e19c15..12416c7 100644
--- a/backend/src/views/apps/crm/project/components/edit.vue
+++ b/backend/src/views/apps/crm/project/components/edit.vue
@@ -2,7 +2,7 @@
+
+
+
取消
保存
@@ -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() {
}
}
+
+
diff --git a/go/controllers/backend_crm_contract.go b/go/controllers/backend_crm_contract.go
index 3fdc8a9..b5d655c 100644
--- a/go/controllers/backend_crm_contract.go
+++ b/go/controllers/backend_crm_contract.go
@@ -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)
diff --git a/go/controllers/backend_crm_pipeline_common.go b/go/controllers/backend_crm_pipeline_common.go
index 33a791b..8b8344e 100644
--- a/go/controllers/backend_crm_pipeline_common.go
+++ b/go/controllers/backend_crm_pipeline_common.go
@@ -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",
diff --git a/go/controllers/backend_crm_product.go b/go/controllers/backend_crm_product.go
new file mode 100644
index 0000000..a51807d
--- /dev/null
+++ b/go/controllers/backend_crm_product.go
@@ -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))
+}
diff --git a/go/controllers/backend_crm_product_category.go b/go/controllers/backend_crm_product_category.go
new file mode 100644
index 0000000..ae7b40f
--- /dev/null
+++ b/go/controllers/backend_crm_product_category.go
@@ -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)
+}
diff --git a/go/controllers/backend_crm_project.go b/go/controllers/backend_crm_project.go
index 65696d7..c09c8ff 100644
--- a/go/controllers/backend_crm_project.go
+++ b/go/controllers/backend_crm_project.go
@@ -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)
diff --git a/go/docs/sql/alter_backend_crm_project_products_column.sql b/go/docs/sql/alter_backend_crm_project_products_column.sql
new file mode 100644
index 0000000..3eb1b53
--- /dev/null
+++ b/go/docs/sql/alter_backend_crm_project_products_column.sql
@@ -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;
diff --git a/go/docs/sql/yz_backend_crm_product.sql b/go/docs/sql/yz_backend_crm_product.sql
new file mode 100644
index 0000000..788647a
--- /dev/null
+++ b/go/docs/sql/yz_backend_crm_product.sql
@@ -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产品管理表';
diff --git a/go/docs/sql/yz_backend_crm_product_category.sql b/go/docs/sql/yz_backend_crm_product_category.sql
new file mode 100644
index 0000000..4dd1789
--- /dev/null
+++ b/go/docs/sql/yz_backend_crm_product_category.sql
@@ -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产品分类表';
diff --git a/go/models/init.go b/go/models/init.go
index 50adf65..8c35f33 100644
--- a/go/models/init.go
+++ b/go/models/init.go
@@ -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() {
diff --git a/go/models/tenant_crm_product.go b/go/models/tenant_crm_product.go
new file mode 100644
index 0000000..c32094b
--- /dev/null
+++ b/go/models/tenant_crm_product.go
@@ -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"
+}
+
+
diff --git a/go/models/tenant_crm_product_category.go b/go/models/tenant_crm_product_category.go
new file mode 100644
index 0000000..1a36501
--- /dev/null
+++ b/go/models/tenant_crm_product_category.go
@@ -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"
+}
+
+
diff --git a/go/models/tenant_crm_project.go b/go/models/tenant_crm_project.go
index cf9543d..946d85a 100644
--- a/go/models/tenant_crm_project.go
+++ b/go/models/tenant_crm_project.go
@@ -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"`
diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go
index b35ee88..4a33c30 100644
--- a/go/routers/backend/backend.go
+++ b/go/routers/backend/backend.go
@@ -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")