优化合同个管理和产品管理相关功能

This commit is contained in:
2026-09-12 23:29:45 +08:00
parent 2b1874943f
commit 4455b95e37
92 changed files with 5367 additions and 3144 deletions
+14
View File
@@ -0,0 +1,14 @@
import request from "@/utils/request";
/**
* CRM 数据仪表盘接口
*
* 说明:
* - range 维度:day(天) / week(周) / month(月) / quarter(季度) / year(年);
* - 后端按租户 + 时间维度聚合真实业务数据(客户/联系人/项目/合同/线索/商机/回访/回款)。
*/
/** 仪表盘汇总数据 */
export function getCrmDashboard(params) {
return request({ url: "/backend/crm/dashboard", method: "get", params });
}
+49
View File
@@ -0,0 +1,49 @@
import request from "@/utils/request";
/**
* CRM 回款管理接口(针对于合同)
*
* 说明:
* - 回款计划必须挂靠合同(contract_id);
* - plan_type 回款周期:1月度/2季度/3年度/4进度/5自定义,决定 items 明细结构;
* 月度/季度/年度:{seq,name,amount};
* 进度:{seq,name,percent,amount};
* 自定义:{seq,name,plan_date,amount};
* - pay_method 回款方式:1对公转账/2网银转账/3现金/4支票/5支付宝/6微信/7其他;
* - items 每行带 received_amount/received_date/status,用于回款进度登记。
*/
/** 回款计划列表 */
export function getPaybackList(params) {
return request({ url: "/backend/crm/payback/list", method: "get", params });
}
/** 回款计划详情 */
export function getPaybackDetail(id) {
return request({ url: `/backend/crm/payback/${id}`, method: "get" });
}
/** 创建回款计划 */
export function createPayback(data) {
return request({ url: "/backend/crm/payback", method: "post", data });
}
/** 更新回款计划 */
export function updatePayback(id, data) {
return request({ url: `/backend/crm/payback/${id}`, method: "put", data });
}
/** 删除回款计划 */
export function deletePayback(id) {
return request({ url: `/backend/crm/payback/${id}`, method: "delete" });
}
/** 回款进度列表(跨计划摊平的分期明细 + 汇总) */
export function getPaybackProgress(params) {
return request({ url: "/backend/crm/payback/progress/list", method: "get", params });
}
/** 回款进度登记:按明细序号写入实收金额 / 回款日期 */
export function registerPaybackReceive(id, data) {
return request({ url: `/backend/crm/payback/${id}/receive`, method: "post", data });
}
@@ -0,0 +1,165 @@
<template>
<el-dialog
:model-value="visible"
:title="title || '软件开发模块报价'"
width="860px"
top="5vh"
append-to-body
@update:model-value="(v) => emit('update:visible', v)"
@open="syncLocal"
>
<div class="dev-tree">
<div class="tree-toolbar">
<el-button type="primary" size="small" :icon="Plus" @click="addRoot">添加顶级模块</el-button>
<span class="tree-tip">叶子模块只填「人天」,人天单价统一填写;父级自动汇总;可任意层级向下拆分</span>
</div>
<div class="unit-price-bar">
<span class="up-label">统一人天单价(元 / 人天)</span>
<el-input-number v-model="unitPrice" :min="0" :precision="2" :controls="false" style="width: 180px" />
<span class="up-total">开发总报价:<b>¥{{ formatMoney(treeSellTotal(tree, unitPrice)) }}</b></span>
</div>
<div v-if="tree.length" class="tree-head">
<span class="th-name">模块名称</span>
<span class="th-md">人天</span>
<span class="th-amount">小计(元)</span>
<span class="th-ops">操作</span>
</div>
<ModuleNode
v-for="n in tree"
:key="n.__id"
:node="n"
:depth="0"
:unit-price="unitPrice"
@remove="removeNode"
/>
<el-empty v-if="!tree.length" description="暂无模块,点击「添加顶级模块」开始" />
</div>
<template #footer>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { treeSellTotal } from "./utils";
import ModuleNode from "./ModuleNode.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
modelValue: { type: Array, default: () => [] },
unitPrice: { type: Number, default: 0 },
title: { type: String, default: "" },
});
const emit = defineEmits(["update:visible", "update:modelValue", "update:unitPrice"]);
const tree = ref([]);
const unitPrice = ref(0);
let uid = 0;
const newNode = () => ({
__id: `m_${Date.now()}_${uid++}`,
name: "",
children: [],
man_days: 0,
});
const syncLocal = () => {
tree.value = Array.isArray(props.modelValue) ? JSON.parse(JSON.stringify(props.modelValue)) : [];
unitPrice.value = Number(props.unitPrice) || 0;
};
watch(tree, (val) => emit("update:modelValue", val), { deep: true });
watch(unitPrice, (val) => emit("update:unitPrice", val));
const addRoot = () => tree.value.push(newNode());
/** 递归从树中移除指定 __id 节点 */
const removeNode = (target) => {
const cut = (list) => {
const idx = list.findIndex((n) => n.__id === target.__id);
if (idx >= 0) {
list.splice(idx, 1);
return true;
}
return list.some((n) => Array.isArray(n.children) && cut(n.children));
};
cut(tree.value);
};
const handleConfirm = () => emit("update:visible", false);
</script>
<style lang="less" scoped>
.dev-tree {
.tree-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
.tree-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.unit-price-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
margin-bottom: 10px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.up-label {
font-size: 13px;
font-weight: 600;
}
.up-total {
margin-left: auto;
font-size: 13px;
color: var(--el-text-color-regular);
b {
color: var(--el-color-primary);
}
}
}
.tree-head {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 4px 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 600;
.th-name {
flex: 1;
min-width: 160px;
}
.th-md {
width: 120px;
}
.th-amount {
width: 120px;
text-align: right;
}
.th-ops {
width: 96px;
text-align: center;
}
}
}
</style>
@@ -0,0 +1,119 @@
<template>
<div class="module-node">
<div class="node-row" :style="{ paddingLeft: depth * 22 + 'px' }">
<span class="node-bar" :style="{ background: depthColor }" />
<el-input v-model="node.name" class="node-name" placeholder="模块名称(如:Web端 / 用户中心)" maxlength="60" />
<template v-if="hasChildren">
<span class="node-agg">汇总</span>
<span class="node-amount">{{ formatMoney(treeSellTotal([node], unitPrice)) }}</span>
</template>
<template v-else>
<el-input-number
v-model="node.man_days"
:min="0"
:precision="1"
:controls="false"
class="node-md"
placeholder="人天"
/>
<span class="node-amount">{{ formatMoney((Number(node.man_days) || 0) * (Number(unitPrice) || 0)) }}</span>
</template>
<div class="node-ops">
<el-button link type="primary" size="small" @click="addChild">+ 子级</el-button>
<el-button link type="danger" size="small" @click="emit('remove', node)">删除</el-button>
</div>
</div>
<ModuleNode
v-for="c in node.children"
:key="c.__id"
:node="c"
:depth="depth + 1"
:unit-price="unitPrice"
@remove="(n) => emit('remove', n)"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import { formatMoney } from "../../dict";
import { treeSellTotal } from "./utils";
defineOptions({ name: "ModuleNode" });
const props = defineProps({
node: { type: Object, required: true },
depth: { type: Number, default: 0 },
unitPrice: { type: Number, default: 0 },
});
const emit = defineEmits(["remove"]);
let uid = 0;
const newNode = () => ({
__id: `m_${Date.now()}_${uid++}`,
name: "",
children: [],
man_days: 0,
});
const hasChildren = computed(() => Array.isArray(props.node.children) && props.node.children.length > 0);
const depthColor = ["#409EFF", "#67C23A", "#E6A23C", "#909399", "#9254DE"][props.depth % 5];
const addChild = () => {
if (!Array.isArray(props.node.children)) props.node.children = [];
props.node.children.push(newNode());
};
</script>
<style lang="less" scoped>
.module-node {
.node-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
margin: 6px 0;
.node-bar {
width: 4px;
align-self: stretch;
border-radius: 2px;
flex-shrink: 0;
}
.node-name {
flex: 1;
min-width: 160px;
}
.node-agg {
font-size: 12px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.node-md {
width: 120px;
flex-shrink: 0;
}
.node-amount {
width: 120px;
text-align: right;
font-weight: 600;
color: var(--el-color-primary);
flex-shrink: 0;
}
.node-ops {
display: flex;
gap: 4px;
flex-shrink: 0;
}
}
}
</style>
@@ -2,9 +2,18 @@
<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="行类型" width="110">
<template #default="{ row }">
<el-select v-model="row.line_type" style="width: 100%" @change="() => onLineTypeChange(row)">
<el-option label="成品" value="product" />
<el-option label="软件开发" value="dev" />
</el-select>
</template>
</el-table-column>
<el-table-column label="产品类别" width="150">
<template #default="{ row }">
<el-select
v-if="row.line_type === 'product'"
v-model="row.category"
filterable
clearable
@@ -19,10 +28,17 @@
:value="c.name"
/>
</el-select>
<span v-else class="muted">软件开发</span>
</template>
</el-table-column>
<el-table-column label="产品名称" min-width="210">
<template #default="{ row }">
<template v-if="row.line_type === 'dev'">
<el-button link type="primary" @click="openDev(row)">
📁 模块报价({{ (row.dev_tree || []).length }} 项 · ¥{{ formatMoney(lineAmount(row)) }})
</el-button>
</template>
<template v-else>
<el-select
v-model="row.name"
filterable
@@ -50,20 +66,24 @@
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
</div> -->
</template>
</template>
</el-table-column>
<el-table-column label="规格型号" width="130">
<template #default="{ row }">
<el-input v-model="row.spec" :placeholder="specPlaceholder(row)" />
<el-input v-if="row.line_type === 'product'" v-model="row.spec" :placeholder="specPlaceholder(row)" />
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="单位" width="90">
<template #default="{ row }">
<el-input v-model="row.unit" placeholder="套" maxlength="10" />
<el-input v-if="row.line_type === 'product'" v-model="row.unit" placeholder="套" maxlength="10" />
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="数量" width="110">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.quantity"
:min="0"
:precision="2"
@@ -71,11 +91,13 @@
placeholder="0"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="单价(元)" width="130">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.price"
:min="0"
:precision="2"
@@ -83,10 +105,12 @@
placeholder="0.00"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="成本单价(元)" width="150">
<template #default="{ row }">
<template v-if="row.line_type === 'product'">
<el-tooltip content="成本单价取自产品管理,关联产品后不可修改" :disabled="!row.product_id">
<el-input-number
v-model="row.cost_price"
@@ -99,10 +123,13 @@
/>
</el-tooltip>
</template>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="税率(%)" width="110">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.tax_rate"
:min="0"
:max="100"
@@ -110,11 +137,12 @@
:controls="false"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="小计(元)" width="120" align="right">
<template #default="{ row }">
<span class="line-amount">{{ formatMoney(rowAmount(row)) }}</span>
<span class="line-amount">{{ formatMoney(lineAmount(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
@@ -132,7 +160,7 @@
<div class="add-row">
<el-button :icon="Plus" @click="addRow">添加产品</el-button>
<span class="add-tip">
先选产品类别,再在该分类下搜索/选择产品(自动带出规格、单位、成本、税率);分类内无对应名称可输入新增,提交时自动建档到产品管理
每行可选「成品」或「软件开发」:成品按数量×单价计价;软件开发点「模块报价」编辑分层模块树(叶子填人天/人天单价,父级自动汇总),统一计入合同总价
</span>
</div>
@@ -170,6 +198,15 @@
</div>
</div>
</div>
<DevModuleTree
v-if="editingRow"
v-model="editingRow.dev_tree"
:unit-price="editingRow.dev_unit_price"
v-model:visible="devVisible"
@update:unit-price="(v) => (editingRow.dev_unit_price = v)"
:title="editingRow.name ? editingRow.name + ' - 模块报价' : '软件开发模块报价'"
/>
</div>
</template>
@@ -177,9 +214,10 @@
import { ref, computed, onMounted } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { buildSummary } from "./utils";
import { buildSummary, lineAmount } from "./utils";
import { getProductList } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import DevModuleTree from "./DevModuleTree.vue";
const props = defineProps({
/** 产品行数组(父组件持有) */
@@ -196,6 +234,7 @@ let rowSeed = 0;
const addRow = () => {
props.products.push({
__key: `row_${Date.now()}_${rowSeed++}`,
line_type: "product",
name: "",
product_id: null,
category: "",
@@ -205,6 +244,8 @@ const addRow = () => {
price: 0,
cost_price: 0,
tax_rate: 0,
dev_tree: [],
dev_unit_price: 0,
remark: "",
});
};
@@ -213,8 +254,23 @@ const removeRow = (index) => {
props.products.splice(index, 1);
};
const rowAmount = (row) =>
Math.round(((Number(row.quantity) || 0) * (Number(row.price) || 0) || 0) * 100) / 100;
/** 切换行类型:软件开发行确保有模块树容器 */
function onLineTypeChange(row) {
if (row.line_type === "dev") {
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
if (row.dev_unit_price == null) row.dev_unit_price = 0;
}
}
/* ------------------------------ 软件开发模块树编辑 ------------------------------ */
const devVisible = ref(false);
const editingRow = ref(null);
function openDev(row) {
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
editingRow.value = row;
devVisible.value = true;
}
const specPlaceholder = (row) => {
const cat = String(row.category || "");
@@ -273,6 +329,15 @@ function onProductPick(row, val) {
const hit = productOptions.value.find((p) => p.product_name === val);
if (hit) {
row.product_id = hit.id;
// 软件开发产品:带入模块树与人天单价,行类型自动切为 dev
if (hit.line_type === "dev" || hit.dev_tree) {
row.line_type = "dev";
row.dev_unit_price = Number(hit.dev_unit_price) || 0;
row.dev_tree = Array.isArray(hit.dev_tree)
? JSON.parse(JSON.stringify(hit.dev_tree))
: safeParseTree(hit.dev_tree);
return;
}
row.spec = hit.spec || "";
row.unit = hit.unit || "";
row.price = hit.price || 0;
@@ -284,6 +349,17 @@ function onProductPick(row, val) {
}
}
/** 安全解析产品管理返回的 dev_tree(可能为字符串或数组) */
function safeParseTree(v) {
if (Array.isArray(v)) return v;
try {
const parsed = typeof v === "string" ? JSON.parse(v) : v;
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
onMounted(() => {
fetchCategories();
// 为已加载的合同产品预置下拉选项,保证名称回显
@@ -311,6 +387,10 @@ onMounted(() => {
color: var(--el-text-color-primary);
}
.muted {
color: var(--el-text-color-placeholder);
}
.opt-name {
float: left;
}
@@ -8,11 +8,16 @@
@update:model-value="handleClose"
@closed="handleClosed"
>
<!-- 进度条 -->
<!-- 进度条(支持点击步骤直接跳转) -->
<div class="steps-wrapper">
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="合同信息" description="关键点信息与各方签约主体" />
<el-step title="产品清单" description="产品明细与金额汇总" />
<el-steps :active="activeStep" finish-status="success" align-center class="clickable-steps">
<el-step
title="合同信息"
description="关键点信息与各方签约主体"
class="step-clickable"
@click="goStep(0)"
/>
<el-step title="产品清单" description="产品明细与金额汇总" class="step-clickable" @click="goStep(1)" />
</el-steps>
</div>
@@ -103,8 +108,8 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目负责人" prop="owner_name">
<el-input v-model="form.owner_name" placeholder="默认为创建人" />
<el-form-item label="项目负责人" prop="owner_user_name">
<el-input v-model="form.owner_user_name" placeholder="默认为创建人" />
</el-form-item>
</el-col>
</el-row>
@@ -145,6 +150,7 @@
value-format="YYYY-MM-DD"
placeholder="签订日期"
style="width: 100%"
@change="onSignDateChange"
/>
</el-form-item>
</el-col>
@@ -173,6 +179,18 @@
</el-row>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="技术日期" prop="tech_date">
<el-date-picker
v-model="form.tech_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="开发/交付完成日期"
style="width: 240px"
/>
<span class="form-tip">技术日期到期且回款未回完,合同状态将自动转为「执行异常」</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark" style="margin-top: 16px">
@@ -272,10 +290,11 @@ const emptyForm = () => ({
party_count: 2, // 合同形式:2双方 3三方 4四方,默认双方
project_id: null,
project_name: "",
owner_name: authStore.user?.name || "",
owner_user_name: authStore.user?.name || "",
sign_date: "",
effective_date: "",
expire_date: "",
tech_date: "",
remark: "",
status: 1,
step: 1,
@@ -395,6 +414,19 @@ watch(
}
);
/** 选择签订日期:生效日期为空时默认带入(生效日期仍可手动修改,改过则不再被覆盖) */
watch(
() => form.sign_date,
(nv) => {
if (nv && !form.effective_date) form.effective_date = nv;
}
);
/** 签订日期变更(用户操作):自动带入生效日期 */
function onSignDateChange(val) {
if (val && !form.effective_date) form.effective_date = val;
}
/** 合同形式文案:2双方 3三方 4四方 */
const partyCountName = (v) => ({ 2: "双方", 3: "三方", 4: "四方" }[Number(v)] || "双方");
@@ -455,6 +487,7 @@ watch(
form.sign_date = normalizeDate(props.editData.sign_date);
form.effective_date = normalizeDate(props.editData.effective_date);
form.expire_date = normalizeDate(props.editData.expire_date);
form.tech_date = normalizeDate(props.editData.tech_date);
parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p }));
products.value = (props.editData.products || []).map((row, i) => ({
...row,
@@ -524,6 +557,13 @@ const prevStep = () => {
if (activeStep.value > 0) activeStep.value--;
};
/** 点击上方步骤条直接跳转(无需按上一步 / 下一步) */
const goStep = (index) => {
const target = Math.max(0, Math.min(MAX_STEP, Number(index) || 0));
if (target === activeStep.value) return;
activeStep.value = target;
};
/** 组装保存 payload;数值字段统一转数字(后端 int8 解析),summary 由产品清单实时计算 */
const buildPayload = (step, status) => ({
...form,
@@ -531,6 +571,7 @@ const buildPayload = (step, status) => ({
sign_date: normalizeDate(form.sign_date),
effective_date: normalizeDate(form.effective_date),
expire_date: normalizeDate(form.expire_date),
tech_date: normalizeDate(form.tech_date),
our_role: Number(form.our_role) || 2,
party_count: Number(form.party_count) || 2,
parties: parties.value.map((p) => ({ ...p })),
@@ -633,6 +674,18 @@ const handleClosed = () => {
padding: 0 24px;
}
/* 步骤条可点击跳转 */
.clickable-steps {
:deep(.step-clickable) {
cursor: pointer;
}
:deep(.el-step__head),
:deep(.el-step__main) {
cursor: pointer;
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
@@ -1,143 +1,88 @@
<template>
<el-drawer
:model-value="visible"
title="合同详情"
size="680px"
:title="detail?.contract_name || '合同详情'"
direction="rtl"
size="900px"
:close-on-click-modal="false"
@update:model-value="emit('update:visible', $event)"
@update:model-value="handleClose"
@opened="loadAll"
>
<div v-loading="loading" class="contract-detail">
<template v-if="detail.id">
<div class="detail-head">
<div class="detail-title">
<span class="name">{{ detail.contract_name }}</span>
<el-tag :type="contractStatusTag(detail.status)" size="small">
{{ contractStatusText(detail.status) }}
</el-tag>
<el-tag type="primary" size="small" effect="plain">
我方·{{ ourRoleText(detail.our_role) }}
</el-tag>
<div v-if="detail.id" class="detail-toolbar">
<div class="toolbar-status">
<!-- <el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag> -->
</div>
<div class="detail-sub">
{{ detail.contract_no }} · {{ contractCategoryText(detail.contract_category) }}
<div class="toolbar-actions">
<el-dropdown trigger="click" @command="(cmd) => handleChangeStatus(cmd)">
<el-button link type="warning" size="small">
状态流转<el-icon style="margin-left: 2px"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="i in statusOptionsExcept(detail.status)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="primary" size="small" :icon="Edit" @click="handleEdit">编辑</el-button>
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
</div>
</div>
<el-tabs v-if="detail.id" v-model="activeTab" tab-position="left" class="detail-tabs">
<!-- 基本信息(拆分自原详情内容) -->
<el-tab-pane label="基本信息" name="basic">
<DetailBasic :detail="detail" />
</el-tab-pane>
<el-descriptions :column="2" border style="margin-top: 16px">
<el-descriptions-item label="所属项目" :span="2">
<template v-if="detail.project_id">
<el-tag size="small" type="primary" effect="light">项目合同</el-tag>
<span style="margin-left: 8px">{{ detail.project_name }}</span>
</template>
<template v-else>
<el-tag size="small" type="warning" effect="light">无头合同</el-tag>
</template>
</el-descriptions-item>
<el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="项目负责人">{{ detail.owner_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<!-- 回款计划(针对本合同创建 / 查看回款计划) -->
<el-tab-pane :label="`回款计划 (${counts.payback})`" name="payback">
<DetailPayback ref="paybackRef" :contract="detail" @count="counts.payback = $event" @saved="emit('refresh')" />
</el-tab-pane>
<el-divider content-position="left">各方签约主体</el-divider>
<el-table :data="detail.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="160" 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 prop="signer_name" label="签约人" width="100">
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
</el-table-column>
<el-table-column prop="signer_phone" label="联系电话" width="120">
<template #default="{ row }">{{ row.signer_phone || "-" }}</template>
</el-table-column>
</el-table>
<!-- 电子合同(上传 / 查看合同扫描件、电子合同正本) -->
<el-tab-pane :label="`电子合同 (${counts.econtract})`" name="econtract">
<DetailAttach
ref="econtractRef"
:related-id="detail.id"
:related-type="E_CONTRACT_TYPE"
tip="上传合同扫描件 / 电子合同正本,支持图片与 PDF"
/>
</el-tab-pane>
<el-divider content-position="left">产品清单</el-divider>
<el-table :data="detail.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 prop="remark" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
</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(summary.total_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
<span class="value warning">¥{{ formatMoney(summary.hardware_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">软件部分金额</span>
<span class="value primary">¥{{ formatMoney(summary.software_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">其他部分金额</span>
<span class="value">¥{{ formatMoney(summary.other_amount) }}</span>
</div>
</div>
</template>
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="log">
<DetailLog ref="logRef" :related-id="detail.id" :related-type="RELATED_TYPE" />
</el-tab-pane>
</el-tabs>
<el-empty v-else-if="!loading" description="暂无数据" />
</div>
</el-drawer>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { getContractDetail } from "@/api/crmContract";
import { PARTY_ROLES, buildSummary } from "./utils";
import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue";
import {
formatMoney,
formatDateOnly,
contractCategoryText,
ourRoleText,
getContractDetail,
changeContractStatus,
deleteContract,
} from "@/api/crmContract";
import {
CONTRACT_STATUS_OPTIONS,
contractStatusText,
contractStatusTag,
productCategoryText,
productCategoryTag,
} from "../../dict";
import DetailBasic from "./detail_basic.vue";
import DetailPayback from "./detail_payback.vue";
import DetailLog from "../../project/components/detail_log.vue";
import DetailAttach from "../../project/components/detail_attach.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -145,23 +90,29 @@ const props = defineProps({
contract: { type: Object, default: null },
});
const emit = defineEmits(["update:visible"]);
const emit = defineEmits(["update:visible", "refresh", "edit"]);
// 合同操作日志与项目共用关联类型 3
const RELATED_TYPE = 3;
// 电子合同附件专属关联类型(与线索 1 / 商机 2 / 项目 3 区分)
const E_CONTRACT_TYPE = 4;
const loading = ref(false);
const detail = ref({});
const activeTab = ref("basic");
const counts = reactive({ payback: 0, econtract: 0 });
const summary = computed(() => {
const s = detail.value.summary;
if (s && Object.keys(s).length) return s;
return buildSummary(detail.value.products || []);
});
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
const paybackRef = ref();
const logRef = ref();
const econtractRef = ref();
watch(
() => props.visible,
async (val) => {
if (!val) return;
activeTab.value = "basic";
counts.payback = 0;
counts.econtract = 0;
detail.value = {};
if (!props.contract?.id) return;
loading.value = true;
@@ -175,6 +126,56 @@ watch(
}
}
);
function handleClose() {
emit("update:visible", false);
}
/** 抽屉打开后刷新各 Tab 数据 */
function loadAll() {
if (!detail.value?.id) return;
paybackRef.value?.reload();
logRef.value?.reload();
econtractRef.value?.reload();
}
/** 状态流转候选:排除当前状态 */
function statusOptionsExcept(cur) {
return CONTRACT_STATUS_OPTIONS.filter((i) => String(i.value) !== String(cur));
}
async function handleChangeStatus(status) {
try {
await changeContractStatus(detail.value.id, Number(status));
ElMessage.success(`状态已更新为「${contractStatusText(status)}」`);
detail.value.status = Number(status);
emit("refresh");
} catch (e) {
ElMessage.error(e.message || "状态更新失败");
}
}
function handleEdit() {
emit("edit", detail.value);
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
`确定删除合同「${detail.value.contract_name}」吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deleteContract(detail.value.id);
ElMessage.success("删除成功");
emit("update:visible", false);
emit("refresh");
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
defineExpose({ loadAll });
</script>
<style lang="less" scoped>
@@ -182,58 +183,72 @@ watch(
padding: 0 4px;
}
.detail-head {
.detail-title {
.detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 12px;
margin-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
.toolbar-status {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
.name {
font-size: 17px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.detail-sub {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
:deep(.el-drawer__body) {
overflow-x: hidden;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.summary-card {
padding: 12px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
.detail-tabs {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
min-width: 0;
.label {
font-size: 12px;
color: var(--el-text-color-secondary);
:deep(.el-tabs__content) {
flex: 1;
min-width: 0;
overflow: hidden;
padding-left: 4px;
}
.value {
font-size: 17px;
font-weight: 700;
:deep(.el-tabs__header) {
margin-right: 12px;
flex-shrink: 0;
}
&.primary {
color: var(--el-color-primary);
:deep(.el-tabs__item) {
justify-content: flex-start;
text-align: left;
padding: 0 12px;
min-width: 96px;
font-size: 13px;
}
&.success {
color: var(--el-color-success);
:deep(.el-tab-pane) {
width: 100%;
min-width: 0;
}
&.warning {
color: var(--el-color-warning);
:deep(.el-table) {
width: 100%;
.el-button.is-link {
padding: 0;
height: auto;
min-height: auto;
}
.el-button + .el-button {
margin-left: 6px;
}
}
}
@@ -0,0 +1,209 @@
<template>
<div class="contract-basic">
<div class="detail-head">
<div class="detail-title">
<span class="name">{{ detail.contract_name || "-" }}</span>
<!-- <el-tag :type="contractStatusTag(detail.status)" size="small">
{{ contractStatusText(detail.status) }}
</el-tag> -->
<el-tag v-if="contractOverdue(detail).overdue" type="danger" size="small" effect="dark">
{{ overdueText(contractOverdue(detail).days) }}
</el-tag>
</div>
<!-- <div class="detail-sub">
{{ detail.contract_no || "-" }}
</div> -->
</div>
<el-descriptions :column="2" border style="margin-top: 16px">
<el-descriptions-item label="合同类型">
<el-tag v-if="detail.project_id" size="small" type="primary" effect="light">项目合同</el-tag>
<el-tag v-else size="small" type="warning" effect="light">无头合同</el-tag>
<el-tag size="small" type="warning" effect="light" style="margin-left: 8px">{{ contractCategoryText(detail.contract_category) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="合同编号">{{ detail.contract_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="履约状态">
<el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="我方角色">{{ ourRoleText(detail.our_role) }}</el-descriptions-item>
<el-descriptions-item label="所属项目" :span="2">
<span v-if="detail.project_id">{{ detail.project_name }}</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="项目负责人">{{ detail.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item>
<el-descriptions-item label="技术日期">{{ formatDateOnly(detail.tech_date) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">各方签约主体</el-divider>
<el-table :data="detail.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="160" 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 prop="signer_name" label="签约人" width="100">
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
</el-table-column>
<el-table-column prop="signer_phone" 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="detail.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 prop="remark" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
</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(summary.total_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
<span class="value warning">¥{{ formatMoney(summary.hardware_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">软件部分金额</span>
<span class="value primary">¥{{ formatMoney(summary.software_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">其他部分金额</span>
<span class="value">¥{{ formatMoney(summary.other_amount) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { PARTY_ROLES, buildSummary } from "./utils";
import {
formatMoney,
formatDateOnly,
contractCategoryText,
ourRoleText,
contractStatusText,
contractStatusTag,
contractOverdue,
overdueText,
productCategoryText,
productCategoryTag,
} from "../../dict";
const props = defineProps({
/** 合同详情完整数据 */
detail: { type: Object, default: () => ({}) },
});
const summary = computed(() => {
const s = props.detail.summary;
if (s && Object.keys(s).length) return s;
return buildSummary(props.detail.products || []);
});
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
</script>
<style lang="less" scoped>
.detail-head {
.detail-title {
display: flex;
align-items: center;
gap: 8px;
.name {
font-size: 17px;
font-weight: 600;
margin-top: 10px;
color: var(--el-text-color-primary);
}
}
.detail-sub {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.summary-card {
padding: 12px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
display: flex;
flex-direction: column;
gap: 6px;
.label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.value {
font-size: 17px;
font-weight: 700;
&.primary {
color: var(--el-color-primary);
}
&.success {
color: var(--el-color-success);
}
&.warning {
color: var(--el-color-warning);
}
}
}
}
</style>
@@ -0,0 +1,151 @@
<template>
<div class="contract-payback">
<div class="section-head">
<span class="section-title">回款计划</span>
<el-button type="primary" size="small" :icon="Plus" @click="openCreate">新建回款计划</el-button>
</div>
<el-table v-loading="loading" :data="list" border size="small">
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="回款周期" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="回款进度" min-width="140" align="center">
<template #default="{ row }">
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
</template>
</el-table-column>
<el-table-column label="计划回款总额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
</el-table-column>
<el-table-column label="回款方式" width="100" align="center">
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
</el-table-column>
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="该合同暂无回款计划,点击右上角新建" :image-size="60" />
</template>
</el-table>
<!-- 新建 / 编辑回款计划(预设当前合同) -->
<PaybackCreate
v-model:visible="createVisible"
:preset-contract="contract"
:edit-data="editRow"
@success="handleSaved"
/>
<!-- 回款计划详情 -->
<PaybackDetail v-model:visible="detailVisible" :payback="currentRow" @edit="handleEdit" />
</div>
</template>
<script setup>
import { ref } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { getPaybackList } from "@/api/crmPayback";
import PaybackCreate from "../../payback/components/create.vue";
import PaybackDetail from "../../payback/components/detail.vue";
import {
paybackCycleText,
paybackMethodText,
paybackStatusText,
paybackStatusTag,
formatMoney,
} from "../../dict";
const props = defineProps({
/** 当前合同详情对象(含 id / contract_name / contract_no / parties / summary) */
contract: { type: Object, default: null },
});
const emit = defineEmits(["count", "saved"]);
const loading = ref(false);
const list = ref([]);
const createVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
const editRow = ref(null);
/** 回款进度百分比 */
function paybackPercent(row) {
const total = Number(row.total_amount) || 0;
if (!total) return 0;
return Math.min(100, Math.round((Number(row.received_amount) / total) * 100));
}
function openCreate() {
editRow.value = null;
createVisible.value = true;
}
function openDetail(row) {
currentRow.value = { ...row };
detailVisible.value = true;
}
/** 详情内点击编辑:关闭详情并打开编辑弹窗 */
function handleEdit(row) {
detailVisible.value = false;
editRow.value = { ...row };
createVisible.value = true;
}
function handleSaved() {
reload();
emit("saved");
}
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
const contractId = props.contract?.id;
if (!contractId) {
list.value = [];
emit("count", 0);
return;
}
loading.value = true;
try {
const res = await getPaybackList({ contract_id: contractId, page: 1, pageSize: 100 });
list.value = res?.data?.list || [];
} catch (e) {
list.value = [];
} finally {
loading.value = false;
emit("count", list.value.length);
}
}
defineExpose({ reload });
</script>
<style lang="less" scoped>
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.section-title {
font-size: 14px;
font-weight: 600;
}
}
</style>
@@ -13,6 +13,36 @@
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/**
* 软件开发模块树节点售价合计:
* - 含子节点 → 汇总子节点;
* - 叶子节点 → 人天 × 统一人天单价。
*/
export function treeSellTotal(nodes, unitPrice = 0) {
const list = Array.isArray(nodes) ? nodes : [];
const up = Number(unitPrice) || 0;
return round2(
list.reduce((sum, n) => {
if (Array.isArray(n.children) && n.children.length) {
return sum + treeSellTotal(n.children, up);
}
return sum + round2((Number(n.man_days) || 0) * up);
}, 0)
);
}
/** 单行金额:成品 = 数量×单价;软件开发 = 模块树售价合计(人天×统一人天单价) */
export function lineAmount(row) {
if (row?.line_type === "dev") return treeSellTotal(row.dev_tree, row.dev_unit_price);
return round2((Number(row?.quantity) || 0) * (Number(row?.price) || 0));
}
/** 单行成本:成品 = 数量×成本单价;软件开发不计成本 */
export function lineCost(row) {
if (row?.line_type === "dev") return 0;
return round2((Number(row?.quantity) || 0) * (Number(row?.cost_price) || 0));
}
export function buildSummary(products) {
const rows = Array.isArray(products) ? products : [];
let hardware = 0;
@@ -20,6 +50,11 @@ export function buildSummary(products) {
let other = 0;
let totalCost = 0;
rows.forEach((row) => {
if (row?.line_type === "dev") {
const amount = treeSellTotal(row.dev_tree, row.dev_unit_price);
software += amount; // 软件开发计入软件部分,不计成本
return;
}
const qty = Number(row?.quantity) || 0;
const price = Number(row?.price) || 0;
const costPrice = Number(row?.cost_price) || 0;
+102 -54
View File
@@ -11,23 +11,44 @@
</div>
</div>
<!-- 统计周期 -->
<div class="stat-period-bar">
<el-radio-group v-model="statsPeriod" @change="onPeriodChange">
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="quarter">本季</el-radio-button>
<el-radio-button label="year">本年</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="statsPeriod === 'custom'"
v-model="statsRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:clearable="false"
@change="loadStats"
/>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<span class="stat-label">合同总数</span>
<span class="stat-value">{{ pagination.total }}</span>
<span class="stat-value">{{ stats.total ?? 0 }}</span>
</div>
<div class="stat-card">
<span class="stat-label">合同总金额</span>
<span class="stat-value primary">¥{{ formatMoney(stats.total_amount) }}</span>
<span class="stat-value primary">{{ formatMoney(stats.total_amount) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">产品总成本</span>
<span class="stat-value">¥{{ formatMoney(stats.total_cost) }}</span>
<span class="stat-value">{{ formatMoney(stats.total_cost) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">合同总利润</span>
<span class="stat-value success">¥{{ formatMoney(stats.total_profit) }}</span>
<span class="stat-value" :style="profitStyle(stats.total_profit)">{{ profitText(stats.total_profit) }}</span>
</div>
</div>
@@ -109,40 +130,25 @@
</el-table-column>
<el-table-column label="合同总利润" width="130" align="right">
<template #default="{ row }">
{{ formatMoney(row.summary?.total_profit ?? row.total_profit) }}
<span :style="profitStyle(row.summary?.total_profit ?? row.total_profit)">
{{ profitText(row.summary?.total_profit ?? row.total_profit) }}
</span>
</template>
</el-table-column>
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<el-table-column label="签订日期" width="110" align="center">
<template #default="{ row }">{{ formatDateOnly(row.sign_date) }}</template>
</el-table-column>
<el-table-column label="操作" width="190" align="center" class-name="op-col" fixed="right">
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<!-- 状态流转:草稿 / 已完成 / 履约中 / 执行异常 / 已作废 互切 -->
<el-dropdown trigger="click" @command="(cmd) => handleChangeStatus(row, cmd)">
<el-button link type="warning" size="small">
状态<el-icon style="margin-left: 2px"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="i in statusOptionsExcept(row)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</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>
<el-tag v-if="contractOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(contractOverdue(row).days) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<template #empty><el-empty description="暂无合同" :image-size="80" /></template>
</el-table>
@@ -168,19 +174,22 @@
/>
<!-- 合同详情 -->
<ContractDetail v-model:visible="detailVisible" :contract="currentRow" />
<ContractDetail
v-model:visible="detailVisible"
:contract="currentRow"
@refresh="fetchList"
@edit="onDetailEdit"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh, ArrowDown } from "@element-plus/icons-vue";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import {
getContractList,
getContractStats,
changeContractStatus,
deleteContract,
} from "@/api/crmContract";
import ContractCreate from "./components/create.vue";
import ContractDetail from "./components/detail.vue";
@@ -192,6 +201,8 @@ import {
contractCategoryText,
contractStatusText,
contractStatusTag,
contractOverdue,
overdueText,
formatDateOnly,
formatMoney,
} from "../dict";
@@ -212,18 +223,39 @@ const filters = reactive({
});
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 列表页统计:后端全租户汇总(排除已作废) */
const stats = ref({ total_amount: 0, total_cost: 0, total_profit: 0 });
/** 统计周期:month/quarter/year/custom(自定义选区间) */
const statsPeriod = ref("month");
const statsRange = ref([]);
/** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */
const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 });
async function loadStats() {
try {
const res = await getContractStats();
const params = { period: statsPeriod.value };
if (statsPeriod.value === "custom" && statsRange.value && statsRange.value.length === 2) {
params.start_date = statsRange.value[0];
params.end_date = statsRange.value[1];
}
const res = await getContractStats(params);
stats.value = res?.data || stats.value;
} catch {
/* 统计失败不影响列表 */
}
}
/** 切到「自定义」首次未选区间时,默认当前月,避免卡片空值 */
function onPeriodChange(val) {
if (val === "custom" && (!statsRange.value || statsRange.value.length !== 2)) {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const last = new Date(y, now.getMonth() + 1, 0).getDate();
statsRange.value = [`${y}-${m}-01`, `${y}-${m}-${last}`];
}
loadStats();
}
onMounted(() => {
fetchList();
loadStats();
@@ -269,6 +301,23 @@ function partyName(row, role) {
return hit?.ref_name || "-";
}
/** 利润正负:正数加 + 号,负数保持 - 号 */
function profitText(val) {
const n = Number(val) || 0;
const s = formatMoney(Math.abs(n));
if (n > 0) return "+" + s;
if (n < 0) return "-" + s;
return s;
}
/** 利润正负配色:正利润红色(danger),负利润绿色(success) */
function profitStyle(val) {
const n = Number(val) || 0;
if (n > 0) return { color: "var(--el-color-danger)" };
if (n < 0) return { color: "var(--el-color-success)" };
return {};
}
function openCreate() {
currentRow.value = null;
initStep.value = 1;
@@ -287,25 +336,15 @@ function openDetail(row) {
detailVisible.value = true;
}
/** 点击名称:草稿继续填写,已完成看详情 */
/** 点击名称:统一查看详情(编辑 / 删除请走合同详情) */
function openRow(row) {
if (Number(row.status) === 1) openEdit(row);
else openDetail(row);
openDetail(row);
}
/** 状态流转候选:排除当前状态 */
function statusOptionsExcept(row) {
return CONTRACT_STATUS_OPTIONS.filter((i) => String(i.value) !== String(row.status));
}
async function handleChangeStatus(row, status) {
try {
await changeContractStatus(row.id, Number(status));
ElMessage.success(`状态已更新为「${contractStatusText(status)}」`);
fetchList();
} catch (e) {
ElMessage.error(e.message || "状态更新失败");
}
/** 详情页触发编辑:关闭详情抽屉,打开创建/编辑向导 */
function onDetailEdit(row) {
detailVisible.value = false;
openEdit(row || currentRow.value);
}
async function handleDelete(row) {
@@ -332,6 +371,15 @@ async function handleDelete(row) {
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 12px;
}
.stat-period-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.stat-card {
background: var(--el-bg-color);
@@ -361,9 +409,9 @@ async function handleDelete(row) {
}
}
}
}
/* 操作列:强制水平居中(多按钮换行也整体居中) */
:deep(.el-table .op-col .cell) {
display: flex;
flex-wrap: wrap;
@@ -7,13 +7,13 @@
@update:model-value="handleClose"
@closed="handleClosed"
>
<!-- 步骤条 -->
<!-- 步骤条(支持点击步骤直接跳转) -->
<div class="steps-wrapper">
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="基本信息" />
<el-step title="联系信息" />
<el-step title="开票信息" />
<el-step title="备注信息" />
<el-steps :active="activeStep" finish-status="success" align-center class="clickable-steps">
<el-step title="基本信息" class="step-clickable" @click="goStep(0)" />
<el-step title="联系信息" class="step-clickable" @click="goStep(1)" />
<el-step title="开票信息" class="step-clickable" @click="goStep(2)" />
<el-step title="备注信息" class="step-clickable" @click="goStep(3)" />
</el-steps>
</div>
@@ -382,6 +382,13 @@ function prevStep() {
}
}
/** 点击上方步骤条直接跳转(无需按上一步 / 下一步) */
function goStep(index) {
const target = Math.max(0, Math.min(3, Number(index) || 0));
if (target === activeStep.value) return;
activeStep.value = target;
}
async function handleSave(isDraft) {
if (!formRef.value) return;
if (isDraft) {
@@ -441,6 +448,15 @@ function handleSaveDraft() {
padding: 0 20px;
}
/* 步骤条可点击跳转 */
.clickable-steps {
:deep(.step-clickable),
:deep(.el-step__head),
:deep(.el-step__main) {
cursor: pointer;
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
+84 -214
View File
@@ -3,7 +3,7 @@
<!-- 顶部工具栏:时间维度切换 -->
<div class="toolbar">
<div class="toolbar-left">
<div class="title">客户关系管理 · 数据仪表盘</div>
<div class="title">数据仪表盘</div>
<div class="subtitle">
<el-icon><Calendar /></el-icon>
<span>{{ rangeText }}</span>
@@ -169,6 +169,7 @@ import {
Wallet,
Phone,
} from "@element-plus/icons-vue";
import { getCrmDashboard } from "@/api/crmDashboard";
/* ------------------------------ 类型定义 ------------------------------ */
type RangeKey = "day" | "week" | "month" | "quarter" | "year";
@@ -179,7 +180,6 @@ interface StatMeta {
label: string;
type: StatType;
icon: unknown;
base: Record<RangeKey, number>;
}
interface StatItem extends StatMeta {
@@ -241,171 +241,55 @@ const rangeText = computed(() => {
/* ------------------------------ 统计卡片 ------------------------------ */
const statMeta: StatMeta[] = [
{
key: "newCustomer",
label: "新增客户",
type: "count",
icon: markRaw(UserFilled),
base: { day: 8, week: 42, month: 168, quarter: 486, year: 1935 },
},
{
key: "newContact",
label: "新增联系人",
type: "count",
icon: markRaw(Postcard),
base: { day: 15, week: 86, month: 342, quarter: 998, year: 3960 },
},
{
key: "newProject",
label: "新增项目",
type: "count",
icon: markRaw(FolderOpened),
base: { day: 3, week: 16, month: 62, quarter: 186, year: 742 },
},
{
key: "newContract",
label: "新增合同",
type: "count",
icon: markRaw(Tickets),
base: { day: 5, week: 24, month: 96, quarter: 288, year: 1150 },
},
{
key: "newClue",
label: "新增线索",
type: "count",
icon: markRaw(Aim),
base: { day: 26, week: 152, month: 610, quarter: 1820, year: 7250 },
},
{
key: "newChance",
label: "新增商机",
type: "count",
icon: markRaw(TrendCharts),
base: { day: 12, week: 68, month: 268, quarter: 800, year: 3180 },
},
{
key: "projectAmount",
label: "项目金额",
type: "amount",
icon: markRaw(Files),
base: {
day: 380000,
week: 1980000,
month: 7860000,
quarter: 23500000,
year: 93800000,
},
},
{
key: "contractAmount",
label: "合同金额",
type: "amount",
icon: markRaw(Document),
base: {
day: 260000,
week: 1360000,
month: 5420000,
quarter: 16200000,
year: 64800000,
},
},
{
key: "chanceAmount",
label: "商机金额",
type: "amount",
icon: markRaw(Money),
base: {
day: 520000,
week: 2760000,
month: 10900000,
quarter: 32600000,
year: 130000000,
},
},
{
key: "paymentAmount",
label: "回款金额",
type: "amount",
icon: markRaw(Wallet),
base: {
day: 210000,
week: 1080000,
month: 4280000,
quarter: 12800000,
year: 51200000,
},
},
{
key: "newVisit",
label: "新增回访",
type: "count",
icon: markRaw(Phone),
base: { day: 18, week: 96, month: 386, quarter: 1150, year: 4600 },
},
{ key: "newCustomer", label: "新增客户", type: "count", icon: markRaw(UserFilled) },
{ key: "newContact", label: "新增联系人", type: "count", icon: markRaw(Postcard) },
{ key: "newProject", label: "新增项目", type: "count", icon: markRaw(FolderOpened) },
{ key: "newContract", label: "新增合同", type: "count", icon: markRaw(Tickets) },
{ key: "newClue", label: "新增线索", type: "count", icon: markRaw(Aim) },
{ key: "newChance", label: "新增商机", type: "count", icon: markRaw(TrendCharts) },
{ key: "projectAmount", label: "项目金额", type: "amount", icon: markRaw(Files) },
{ key: "contractAmount", label: "合同金额", type: "amount", icon: markRaw(Document) },
{ key: "chanceAmount", label: "商机金额", type: "amount", icon: markRaw(Money) },
{ key: "paymentAmount", label: "回款金额", type: "amount", icon: markRaw(Wallet) },
{ key: "newVisit", label: "新增回访", type: "count", icon: markRaw(Phone) },
];
const statCards = ref<StatItem[]>([]);
/* ------------------------------ 列表数据 ------------------------------ */
const rankList = ref([
{ name: "张伟", dept: "销售一部", contract: 1280000, payment: 960000 },
{ name: "李娜", dept: "销售二部", contract: 1060000, payment: 820000 },
{ name: "王强", dept: "销售一部", contract: 950000, payment: 700000 },
{ name: "刘洋", dept: "大客户部", contract: 880000, payment: 640000 },
{ name: "陈静", dept: "销售二部", contract: 760000, payment: 530000 },
{ name: "赵磊", dept: "大客户部", contract: 640000, payment: 510000 },
{ name: "孙悦", dept: "销售三部", contract: 520000, payment: 380000 },
{ name: "周涛", dept: "销售三部", contract: 430000, payment: 300000 },
]);
/* ------------------------------ 列表数据(真实接口返回) ------------------------------ */
interface RankItem {
name: string;
dept: string;
contract: number;
payment: number;
}
interface VisitItem {
customer: string;
way: string;
content: string;
user: string;
time: string;
}
const rankList = ref<RankItem[]>([]);
const maxContract = computed(() =>
rankList.value.reduce((max, item) => Math.max(max, item.contract), 1)
);
const visitList = ref<VisitItem[]>([]);
const visitList = ref([
{
customer: "深圳市星辰科技有限公司",
way: "电话",
content: "确认合同条款细节与交付时间节点",
user: "张伟",
time: "09-10 09:30",
},
{
customer: "广州恒远制造集团",
way: "上门",
content: "现场沟通二期项目需求,客户预算已确认",
user: "李娜",
time: "09-10 08:50",
},
{
customer: "杭州云图网络技术公司",
way: "微信",
content: "发送报价单,等待客户内部审批流程",
user: "王强",
time: "09-09 17:20",
},
{
customer: "成都锦程物流有限公司",
way: "电话",
content: "回访首月使用情况,客户反馈良好",
user: "刘洋",
time: "09-09 15:05",
},
{
customer: "北京中启信息技术股份公司",
way: "邮件",
content: "跟进续约意向,客户提出增加 20 个坐席",
user: "陈静",
time: "09-09 11:40",
},
{
customer: "上海瑞丰医疗器械公司",
way: "上门",
content: "演示新版本功能,约下周做正式方案汇报",
user: "赵磊",
time: "09-08 16:30",
},
]);
// 图表数据(趋势 / 金额 / 漏斗 / 来源),由接口返回
interface DashData {
trend: { axis: string[]; customer: number[]; chance: number[] };
funnel: { name: string; value: number }[];
amount: { axis: string[]; contract: number[]; payment: number[] };
source: { name: string; value: number }[];
}
const dash = ref<DashData>({
trend: { axis: [], customer: [], chance: [] },
funnel: [],
amount: { axis: [], contract: [], payment: [] },
source: [],
});
/* ------------------------------ 工具方法 ------------------------------ */
const formatValue = (item: StatItem) => {
@@ -423,36 +307,6 @@ const formatWan = (value: number) => {
const trendClass = (trend: number) =>
trend > 0 ? "up" : trend < 0 ? "down" : "flat";
// 基于基数生成带随机波动的模拟数据(后续替换为接口数据)
const randomBy = (base: number, spread = 0.3) =>
Math.round(base * (1 - spread / 2 + Math.random() * spread));
const getBase = (key: string) =>
statMeta.find((i) => i.key === key)?.base[range.value] ?? 0;
const buildAxis = (): string[] => {
const now = new Date();
switch (range.value) {
case "day":
return Array.from({ length: 12 }, (_, i) => `${pad(i * 2)}:00`);
case "week":
return ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
case "month": {
const days = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
return Array.from({ length: days }, (_, i) => `${i + 1}日`);
}
case "quarter": {
const q = Math.floor(now.getMonth() / 3);
return [0, 1, 2].map((i) => `${q * 3 + i + 1}月`);
}
default:
return Array.from({ length: 12 }, (_, i) => `${i + 1}月`);
}
};
const buildSeries = (total: number, len: number) =>
Array.from({ length: len }, () => randomBy(total / len, 0.6));
/* ------------------------------ 图表 ------------------------------ */
const trendRef = ref<HTMLElement | null>(null);
const funnelRef = ref<HTMLElement | null>(null);
@@ -476,7 +330,7 @@ const getPalette = () => {
const renderTrendChart = () => {
if (!trendChart.value) return;
const p = getPalette();
const axis = buildAxis();
const axis = dash.value.trend.axis;
trendChart.value.setOption(
{
tooltip: { trigger: "axis", axisPointer: { type: "cross" } },
@@ -519,7 +373,7 @@ const renderTrendChart = () => {
{ offset: 1, color: "rgba(57,115,255,0.02)" },
]),
},
data: buildSeries(getBase("newCustomer"), axis.length),
data: dash.value.trend.customer,
},
{
name: "新增商机",
@@ -535,7 +389,7 @@ const renderTrendChart = () => {
{ offset: 1, color: "rgba(16,185,129,0.02)" },
]),
},
data: buildSeries(getBase("newChance"), axis.length),
data: dash.value.trend.chance,
},
],
},
@@ -546,14 +400,12 @@ const renderTrendChart = () => {
const renderFunnelChart = () => {
if (!funnelChart.value) return;
const p = getPalette();
const clue = getBase("newClue");
const data = [
{ name: "线索", value: clue, ratio: 1 },
{ name: "商机", value: Math.round(clue * 0.42), ratio: 0.42 },
{ name: "报价", value: Math.round(clue * 0.22), ratio: 0.22 },
{ name: "合同", value: Math.round(clue * 0.1), ratio: 0.1 },
{ name: "回款", value: Math.round(clue * 0.078), ratio: 0.078 },
];
const maxValue = Math.max(...dash.value.funnel.map((i) => i.value), 1);
const data = dash.value.funnel.map((i) => ({
name: i.name,
value: i.value,
ratio: i.value / maxValue,
}));
funnelChart.value.setOption(
{
tooltip: {
@@ -601,7 +453,7 @@ const renderFunnelChart = () => {
const renderAmountChart = () => {
if (!amountChart.value) return;
const p = getPalette();
const axis = buildAxis();
const axis = dash.value.amount.axis;
amountChart.value.setOption(
{
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
@@ -638,14 +490,14 @@ const renderAmountChart = () => {
type: "bar",
barMaxWidth: 18,
itemStyle: { color: "#f59e0b", borderRadius: [4, 4, 0, 0] },
data: buildSeries(getBase("contractAmount"), axis.length),
data: dash.value.amount.contract,
},
{
name: "回款金额",
type: "bar",
barMaxWidth: 18,
itemStyle: { color: "#eab308", borderRadius: [4, 4, 0, 0] },
data: buildSeries(getBase("paymentAmount"), axis.length),
data: dash.value.amount.payment,
},
],
},
@@ -656,13 +508,7 @@ const renderAmountChart = () => {
const renderSourceChart = () => {
if (!sourceChart.value) return;
const p = getPalette();
const total = getBase("newClue");
const ratio = [0.28, 0.2, 0.16, 0.14, 0.12, 0.1];
const names = ["官网注册", "电话咨询", "展会获客", "客户转介绍", "社交媒体", "广告投放"];
const data = names.map((name, i) => ({
name,
value: Math.round(total * ratio[i] * (0.9 + Math.random() * 0.2)),
}));
const data = dash.value.source;
sourceChart.value.setOption(
{
tooltip: { trigger: "item", formatter: "{b}:{c}({d}%)" },
@@ -720,20 +566,44 @@ const handleResize = () => {
const loadData = async () => {
loading.value = true;
try {
// TODO: 接入真实接口,按 range 维度查询统计数据
// const res = await getCrmDashboard({ range: range.value });
await new Promise((resolve) => setTimeout(resolve, 260));
const res = await getCrmDashboard({ range: range.value });
const data: any = res?.data || {};
const statsMap: Record<string, { value: number; trend: number }> =
data.stats || {};
statCards.value = statMeta.map((meta) => ({
...meta,
value: randomBy(meta.base[range.value]),
trend: Number((Math.random() * 46 - 16).toFixed(1)),
value: statsMap[meta.key]?.value ?? 0,
trend: statsMap[meta.key]?.trend ?? 0,
}));
dash.value = {
trend: data.trend || { axis: [], customer: [], chance: [] },
funnel: data.funnel || [],
amount: data.amount || { axis: [], contract: [], payment: [] },
source: data.source || [],
};
rankList.value = data.rank || [];
visitList.value = data.visits || [];
const now = new Date();
updateTime.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate()
)} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
await nextTick();
renderCharts();
} catch (e) {
statCards.value = statMeta.map((meta) => ({ ...meta, value: 0, trend: 0 }));
dash.value = {
trend: { axis: [], customer: [], chance: [] },
funnel: [],
amount: { axis: [], contract: [], payment: [] },
source: [],
};
rankList.value = [];
visitList.value = [];
await nextTick();
renderCharts();
} finally {
loading.value = false;
}
+133
View File
@@ -263,6 +263,76 @@ export function formatDateOnly(val) {
return `${d.getFullYear()}-${m}-${day}`;
}
/* =====================================================================
* 逾期(overdue)计算
* 规则:到期/结束日期 < 今天 且 状态非「完成/作废」即视为逾期。
* - 项目:结束日期(end_date) 已过 且 状态 != 已完成(3)
* - 合同:到期日期(expire_date) 已过 且 状态 != 已完成(2) / 已作废(3)
* - 回款明细:计划回款日期(plan_date) 已过 且 状态 != 已回款(3)
* 全部以「当前日期」判定,无需服务端额外参数。
* ===================================================================== */
/** 距今天数:今天 - dateStr(按日期比较),>0 表示 dateStr 已在今天之前 */
export function overdueDays(dateStr) {
if (!dateStr) return 0;
const d = new Date(dateStr);
if (isNaN(d.getTime())) return 0;
const t = new Date();
t.setHours(0, 0, 0, 0);
d.setHours(0, 0, 0, 0);
return Math.round((t - d) / 86400000);
}
/** 逾期文案:days>0 -> "逾期 N 天",否则空串 */
export function overdueText(days) {
return days > 0 ? `逾期 ${days} 天` : "";
}
/** 项目逾期信息:{ overdue, days } */
export function projectOverdue(row) {
const days = overdueDays(row?.end_date);
const overdue = days > 0 && Number(row?.status) !== 3;
return { overdue, days: overdue ? days : 0 };
}
/** 合同逾期信息:{ overdue, days } */
export function contractOverdue(row) {
const days = overdueDays(row?.expire_date);
const overdue = days > 0 && ![2, 3].includes(Number(row?.status));
return { overdue, days: overdue ? days : 0 };
}
/** 回款明细逾期信息:{ overdue, days } */
export function paybackItemOverdue(item) {
const days = overdueDays(item?.plan_date);
const overdue = days > 0 && Number(item?.status) !== 3;
return { overdue, days: overdue ? days : 0 };
}
/** 回款计划逾期:任一明细逾期即视为计划逾期(items 可能为数组或 JSON 字符串)。
* 返回 { overdue, days },days 取逾期明细中最大的逾期天数。 */
export function paybackPlanOverdue(payback) {
let items = payback?.items;
if (typeof items === "string" && items.trim()) {
try {
items = JSON.parse(items);
} catch {
items = [];
}
}
if (!Array.isArray(items)) items = [];
let maxDays = 0;
let overdue = false;
for (const it of items) {
const r = paybackItemOverdue(it);
if (r.overdue) {
overdue = true;
if (r.days > maxDays) maxDays = r.days;
}
}
return { overdue, days: overdue ? maxDays : 0 };
}
/* =====================================================================
* 合同管理
* 我方角色 our_role:1=甲方 2=乙方 3=丙方 4=丁方(当前租户扮演的一方,默认乙方)
@@ -417,3 +487,66 @@ export function stripHtml(val) {
if (text) return text;
return /<img/i.test(raw) ? "[图片]" : "-";
}
/* =====================================================================
* 回款管理(针对于合同:回款计划 + 回款进度)
* 回款周期 plan_type:1=月度 2=季度 3=年度 4=进度 5=自定义
* 回款方式 pay_method:1=对公转账 2=网银转账 3=现金 4=支票 5=支付宝 6=微信 7=其他
* 计划状态 status:1=进行中 2=已完成
* 明细状态 item.status:1=未回款 2=部分回款 3=已回款
* ===================================================================== */
/** 回款周期 */
export const PAYBACK_CYCLE_OPTIONS = [
{ label: "月度", value: 1 },
{ label: "季度", value: 2 },
{ label: "年度", value: 3 },
{ label: "进度", value: 4 },
{ label: "自定义", value: 5 },
];
/** 按周期的分期单位(用于生成「第 N 月/季度/年」文案) */
export const PAYBACK_CYCLE_UNIT = { 1: "月", 2: "季度", 3: "年" };
/** 回款方式 */
export const PAYBACK_METHOD_OPTIONS = [
{ label: "对公转账", value: 1 },
{ label: "网银转账", value: 2 },
{ label: "现金", value: 3 },
{ label: "支票", value: 4 },
{ label: "支付宝", value: 5 },
{ label: "微信", value: 6 },
{ label: "其他", value: 7 },
];
/** 回款计划状态 */
export const PAYBACK_STATUS_OPTIONS = [
{ label: "进行中", value: 1 },
{ label: "已完成", value: 2 },
];
/** 回款明细(分期)状态 */
export const PAYBACK_ITEM_STATUS_OPTIONS = [
{ label: "未回款", value: 1 },
{ label: "部分回款", value: 2 },
{ label: "已回款", value: 3 },
];
const PAYBACK_CYCLE_MAP = PAYBACK_CYCLE_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const PAYBACK_METHOD_MAP = PAYBACK_METHOD_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const PAYBACK_STATUS_MAP = PAYBACK_STATUS_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const PAYBACK_ITEM_STATUS_MAP = PAYBACK_ITEM_STATUS_OPTIONS.reduce(
(m, i) => ((m[i.value] = i.label), m),
{}
);
const PAYBACK_STATUS_TAG = { 1: "primary", 2: "success" };
const PAYBACK_ITEM_STATUS_TAG = { 1: "info", 2: "warning", 3: "success" };
export const paybackCycleText = (val) => PAYBACK_CYCLE_MAP[normalize(val)] || normalize(val) || "-";
export const paybackMethodText = (val) => PAYBACK_METHOD_MAP[normalize(val)] || normalize(val) || "-";
export const paybackStatusText = (val) => PAYBACK_STATUS_MAP[normalize(val)] || normalize(val) || "-";
export const paybackStatusTag = (val) => PAYBACK_STATUS_TAG[normalize(val)] || "info";
export const paybackItemStatusText = (val) =>
PAYBACK_ITEM_STATUS_MAP[normalize(val)] || normalize(val) || "-";
export const paybackItemStatusTag = (val) => PAYBACK_ITEM_STATUS_TAG[normalize(val)] || "info";
@@ -0,0 +1,763 @@
<template>
<el-dialog
:model-value="visible"
:title="isEdit ? '编辑回款计划' : '新建回款计划'"
width="880px"
top="5vh"
:close-on-click-modal="false"
@update:model-value="handleClose"
@closed="handleClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="104px">
<!-- 关联合同 / 客户 / 金额 -->
<el-divider content-position="left">合同信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="关联合同" prop="contract_id">
<el-select
v-model="form.contract_id"
filterable
remote
clearable
reserve-keyword
:loading="contractLoading"
:remote-method="searchContracts"
placeholder="搜索合同名称"
style="width: 100%"
@change="handleContractChange"
@visible-change="handleContractDropdown"
>
<el-option
v-for="c in contractOptions"
:key="c.id"
:label="c.contract_name"
:value="c.id"
>
<span class="option-name">{{ c.contract_name }}</span>
<span class="option-sub">{{ c.contract_no }}</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户名称" prop="customer_name">
<el-input v-model="form.customer_name" placeholder="默认取合同客户,可修改" maxlength="128" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同名称" prop="contract_name">
<el-input v-model="form.contract_name" placeholder="选择合同后自动带入" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同总金额" prop="contract_amount">
<el-input-number
v-model="form.contract_amount"
:min="0"
:precision="2"
:controls="false"
placeholder="元"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<!-- 计划配置 -->
<el-divider content-position="left">计划配置</el-divider>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="回款周期" prop="plan_type">
<el-radio-group v-model="form.plan_type" @change="handlePlanTypeChange">
<el-radio-button v-for="i in PAYBACK_CYCLE_OPTIONS" :key="i.value" :value="i.value">
{{ i.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="回款方式" prop="pay_method">
<el-select v-model="form.pay_method" placeholder="请选择" style="width: 100%">
<el-option v-for="i in PAYBACK_METHOD_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人" prop="owner_user_id">
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="提前提醒" prop="remind_days">
<el-input-number v-model="form.remind_days" :min="0" :max="365" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="回款补充说明(选填)" />
</el-form-item>
</el-col>
</el-row>
<!-- 流水回执:银行流水 / 转账凭证,非必填 -->
<el-divider content-position="left">
流水回执
<span class="tip">银行流水 / 转账凭证等,选填</span>
</el-divider>
<div class="receipt-block">
<el-upload
:show-file-list="false"
:http-request="handleReceiptUpload"
:disabled="uploading"
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.xls,.xlsx"
>
<el-button :icon="Upload" :loading="uploading" size="small">上传流水回执</el-button>
</el-upload>
<div v-if="form.receipt_url" class="receipt-file">
<el-link type="primary" :href="form.receipt_url" target="_blank" :underline="false">
<el-icon><Document /></el-icon>
<span class="receipt-name">{{ form.receipt_name || "流水回执" }}</span>
</el-link>
<el-button link type="danger" size="small" :icon="Delete" @click="clearReceipt">移除</el-button>
</div>
</div>
<!-- 计划明细:月度 / 季度 / 年度 = 期数 + 每期金额 -->
<template v-if="isFixedCycle">
<el-divider content-position="left">
回款明细
<span class="tip">按{{ cycleUnitText }}生成分期,共 {{ form.periodCount || 0 }} 期</span>
</el-divider>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item :label="`期数(${cycleUnitText})`">
<el-input-number v-model="form.periodCount" :min="1" :max="120" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="每期金额">
<el-input-number
v-model="form.periodAmount"
:min="0"
:precision="2"
:controls="false"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="计划总额">
<span class="total-amount">¥{{ formatMoney(plannedTotal) }}</span>
</el-form-item>
</el-col>
</el-row>
<el-table :data="fixedItems" border size="small" class="detail-table">
<el-table-column type="index" label="期数" width="70" align="center" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="支付金额" width="140" align="right">
<template #default="{ row }">¥{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="计划回款日期" width="180">
<template #default="{ $index }">
<el-date-picker v-model="form.periodDates[$index]" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</template>
</el-table-column>
<template #empty><el-empty description="请填写期数与每期金额" :image-size="50" /></template>
</el-table>
</template>
<!-- 计划明细:进度 = 进度名称 + 进度百分比 + 进度金额 -->
<template v-else-if="form.plan_type === 4">
<el-divider content-position="left">
回款进度
<span class="tip">按进度节点逐笔回款</span>
</el-divider>
<el-table :data="form.progressItems" border size="small" class="detail-table" row-key="__key">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column label="进度名称" min-width="160">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="如:预付款、验收款" maxlength="50" />
</template>
</el-table-column>
<el-table-column label="进度百分比" width="150">
<template #default="{ row }">
<el-input-number
v-model="row.percent"
:min="0"
:max="100"
:precision="2"
:controls="false"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column width="210">
<template #header>
<span>进度金额</span>
<el-tooltip
content="默认按「合同总金额 × 进度百分比」自动计算;手动填写后该行不再自动计算,可点「重算」恢复"
placement="top"
>
<el-icon class="col-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<div class="amount-cell">
<el-input-number
v-model="row.amount"
:min="0"
:precision="2"
:controls="false"
class="amount-input"
@change="markAmountManual(row)"
/>
<el-button v-if="row.auto === false" link type="primary" size="small" @click="resetRowAuto(row)">
重算
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="计划回款日期" width="180">
<template #default="{ row }">
<el-date-picker v-model="row.plan_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeRow('progressItems', $index)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无进度节点" :image-size="50" /></template>
</el-table>
<el-button class="add-row" :icon="Plus" size="small" @click="addRow('progressItems')">增加进度</el-button>
<span class="sum-tip">计划总额:¥{{ formatMoney(plannedTotal) }}</span>
</template>
<!-- 计划明细:自定义 = 名称 + 计划日期 + 金额 -->
<template v-else>
<el-divider content-position="left">
回款明细
<span class="tip">自定义回款节点</span>
</el-divider>
<el-table :data="form.customItems" border size="small" class="detail-table" row-key="__key">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column label="名称" min-width="160">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="如:首付款" maxlength="50" />
</template>
</el-table-column>
<el-table-column label="计划日期" width="180">
<template #default="{ row }">
<el-date-picker v-model="row.plan_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="金额" width="170">
<template #default="{ row }">
<el-input-number v-model="row.amount" :min="0" :precision="2" :controls="false" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeRow('customItems', $index)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无回款节点" :image-size="50" /></template>
</el-table>
<el-button class="add-row" :icon="Plus" size="small" @click="addRow('customItems')">增加明细</el-button>
<span class="sum-tip">计划总额:¥{{ formatMoney(plannedTotal) }}</span>
</template>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus, QuestionFilled, Upload, Delete, Document } from "@element-plus/icons-vue";
import { createPayback, updatePayback } from "@/api/crmPayback";
import { uploadFile } from "@/api/file";
import { getContractList } from "@/api/crmContract";
import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
import {
PAYBACK_CYCLE_OPTIONS,
PAYBACK_METHOD_OPTIONS,
PAYBACK_CYCLE_UNIT,
formatMoney,
} from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
/** 编辑数据(含 id 与 items) */
editData: { type: Object, default: null },
/** 预设合同(从合同详情「回款计划」进入时使用) */
presetContract: { type: Object, default: null },
/** 预设合同ID(列表选择合同后快捷新建) */
presetContractId: { type: [Number, String], default: null },
});
const emit = defineEmits(["update:visible", "success"]);
const authStore = useAuthStore();
const formRef = ref();
const saving = ref(false);
const isEdit = ref(false);
const internalId = ref(null);
const userOptions = ref([]);
let rowSeed = 0;
const genKey = () => `pb_${Date.now()}_${rowSeed++}`;
const defaultForm = () => ({
contract_id: null,
contract_no: "",
contract_name: "",
customer_id: null,
customer_name: "",
contract_amount: 0,
plan_type: 4,
pay_method: 1,
remind_days: 0,
owner_user_id: authStore.user?.id ? String(authStore.user.id) : "",
owner_user_name: authStore.user?.name || "",
remark: "",
receipt_url: "",
receipt_name: "",
periodCount: 1,
periodAmount: 0,
periodDates: [],
progressItems: [{ __key: genKey(), name: "", percent: 0, amount: 0, plan_date: "", auto: true }],
customItems: [],
});
const form = reactive(defaultForm());
const rules = {
contract_id: [{ required: true, message: "请选择关联合同", trigger: "change" }],
plan_type: [{ required: true, message: "请选择回款周期", trigger: "change" }],
pay_method: [{ required: true, message: "请选择回款方式", trigger: "change" }],
};
/** 固定周期(月度/季度/年度):期数 + 每期金额 */
const isFixedCycle = computed(() => [1, 2, 3].includes(Number(form.plan_type)));
const cycleUnitText = computed(() => PAYBACK_CYCLE_UNIT[Number(form.plan_type)] || "");
const fixedItems = computed(() => {
const n = Number(form.periodCount) || 0;
const amount = Number(form.periodAmount) || 0;
return Array.from({ length: n }, (_, i) => ({
seq: i + 1,
name: `第${i + 1}${cycleUnitText.value}`,
amount,
plan_date: form.periodDates[i] || "",
}));
});
/** 计划总额:按当前周期口径汇总 */
const plannedTotal = computed(() => {
if (isFixedCycle.value) return Number(form.periodCount || 0) * (Number(form.periodAmount) || 0);
const rows = Number(form.plan_type) === 4 ? form.progressItems : form.customItems;
return rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0);
});
/* ------------------------------ 合同搜索 ------------------------------ */
const contractLoading = ref(false);
const contractOptions = ref([]);
const searchContracts = async (keyword) => {
contractLoading.value = true;
try {
const res = await getContractList({ keyword: keyword || "", page: 1, pageSize: 50 });
contractOptions.value = res?.data?.list || [];
} catch {
contractOptions.value = [];
} finally {
contractLoading.value = false;
}
};
const handleContractDropdown = (visible) => {
if (visible && contractOptions.value.length === 0) searchContracts("");
};
const handleContractChange = (id) => {
const hit = contractOptions.value.find((c) => String(c.id) === String(id));
if (!hit) return;
applyContract(hit);
};
/** 用合同数据回填客户 / 合同名称 / 合同金额(客户取合同参与方中的客户) */
function applyContract(contract) {
form.contract_name = contract.contract_name || "";
form.contract_no = contract.contract_no || "";
const amount = contract.summary?.total_amount ?? contract.total_amount;
if (amount !== undefined && amount !== null) form.contract_amount = Number(amount) || 0;
const parties = Array.isArray(contract.parties) ? contract.parties : [];
const customer = parties.find((p) => String(p.ref_type) === "1");
if (customer) {
form.customer_name = customer.ref_name || form.customer_name;
form.customer_id = customer.ref_id || form.customer_id;
}
}
/* ------------------------------ 明细操作 ------------------------------ */
const addRow = (key) => {
if (key === "progressItems") {
const row = { __key: genKey(), name: "", percent: 0, amount: 0, plan_date: "", auto: true };
form.progressItems.push(row);
syncRowAmount(row);
} else {
form.customItems.push({ __key: genKey(), name: "", plan_date: "", amount: 0 });
}
};
const removeRow = (key, index) => {
form[key].splice(index, 1);
};
/* ---------------------- 流水回执上传(非必填) ---------------------- */
const uploading = ref(false);
/** 上传流水回执:复用通用文件上传接口,返回地址存入表单(选填) */
async function handleReceiptUpload(options) {
const file = options.file;
uploading.value = true;
try {
const fd = new FormData();
fd.append("file", file);
const res = await uploadFile(fd);
const data = res?.data || {};
form.receipt_url = data.url || "";
form.receipt_name = data.name || file.name;
ElMessage.success("上传成功");
} catch (e) {
ElMessage.error(e.message || "上传失败");
} finally {
uploading.value = false;
}
}
/** 移除已上传的流水回执 */
function clearReceipt() {
form.receipt_url = "";
form.receipt_name = "";
}
/* ---------------------- 进度金额自动计算 ---------------------- */
/** 金额保留两位小数 */
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/** 单行进度金额 = 合同总金额 × 进度百分比(手动修改过的行不参与) */
function syncRowAmount(row) {
if (!row || row.auto === false) return;
const base = Number(form.contract_amount) || 0;
row.amount = round2((base * (Number(row.percent) || 0)) / 100);
}
/** 全部自动行重算(合同总金额变化 / 百分比变化时调用) */
function syncAllAmounts() {
form.progressItems.forEach((row) => syncRowAmount(row));
}
/** 手动填写进度金额:该行转为手动,不再随百分比自动变化 */
function markAmountManual(row) {
row.auto = false;
}
/** 恢复该行自动计算 */
function resetRowAuto(row) {
row.auto = true;
syncRowAmount(row);
}
/** 合同总金额变化:所有自动行重算 */
watch(() => form.contract_amount, syncAllAmounts);
/** 进度百分比变化:自动行实时重算 */
watch(
() => form.progressItems.map((r) => `${r.__key}#${Number(r.percent) || 0}`).join(","),
syncAllAmounts
);
/** 周期切换:首次进入对应明细时补一行默认数据 */
const handlePlanTypeChange = (val) => {
if (Number(val) === 4 && form.progressItems.length === 0) addRow("progressItems");
if (Number(val) === 5 && form.customItems.length === 0) addRow("customItems");
};
/* ------------------------------ 回显 ------------------------------ */
/** 解析 items(兼容 JSON 字符串 / 数组) */
function parseItems(val) {
if (Array.isArray(val)) return val;
if (typeof val === "string" && val.trim()) {
try {
const arr = JSON.parse(val);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
return [];
}
async function loadUsers() {
if (userOptions.value.length) return;
try {
const res = await getAllUsers();
const data = res?.data || {};
const list = Array.isArray(data) ? data : data.list || [];
userOptions.value = list.map((u) => ({
id: u.uid || u.id,
name: u.name || u.account || `用户${u.uid || u.id}`,
}));
} catch {
userOptions.value = [];
}
}
watch(
() => props.visible,
(val) => {
if (!val) return;
loadUsers();
const preset = props.presetContract;
if (props.editData?.id) {
isEdit.value = true;
internalId.value = props.editData.id;
Object.assign(form, defaultForm(), props.editData);
form.plan_type = Number(props.editData.plan_type) || 1;
form.pay_method = Number(props.editData.pay_method) || 1;
form.remind_days = Number(props.editData.remind_days) || 0;
form.contract_amount = Number(props.editData.contract_amount) || 0;
const items = parseItems(props.editData.items);
if ([1, 2, 3].includes(form.plan_type)) {
form.periodCount = items.length || 1;
form.periodAmount = items.length ? Number(items[0].amount) || 0 : 0;
form.periodDates = items.map((it) => it.plan_date || "");
} else if (form.plan_type === 4) {
// 回显历史数据:保留已保存金额,默认按手动处理(可点「重算」恢复自动)
form.progressItems = items.map((it) => ({ ...it, __key: genKey(), auto: false }));
} else {
form.customItems = items.map((it) => ({ ...it, __key: genKey() }));
}
// 合同下拉回显(不重新拉取,直接补一条)
if (form.contract_id) {
contractOptions.value = [
{
id: form.contract_id,
contract_no: form.contract_no,
contract_name: form.contract_name,
},
];
}
} else {
isEdit.value = false;
internalId.value = null;
Object.assign(form, defaultForm());
contractOptions.value = [];
const source = preset || (props.presetContractId ? { id: props.presetContractId } : null);
if (source) {
let hit = contractOptions.value.find((c) => String(c.id) === String(source.id));
if (source.contract_name) {
hit = { ...source };
contractOptions.value = [hit];
}
form.contract_id = source.id;
if (hit) applyContract(hit);
else form.contract_name = source.contract_name || "";
}
}
}
);
/* ------------------------------ 保存 ------------------------------ */
/** 组装明细:按周期口径输出后端 items 结构 */
function buildItems() {
if (isFixedCycle.value) {
return fixedItems.value.map((it) => ({
name: it.name,
amount: Number(it.amount) || 0,
plan_date: form.periodDates[it.seq - 1] || "",
}));
}
if (Number(form.plan_type) === 4) {
return form.progressItems.map((it) => ({
name: (it.name || "").trim(),
percent: Number(it.percent) || 0,
amount: Number(it.amount) || 0,
plan_date: it.plan_date || "",
}));
}
return form.customItems.map((it) => ({
name: (it.name || "").trim(),
plan_date: it.plan_date || "",
amount: Number(it.amount) || 0,
}));
}
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
const items = buildItems();
if (!items.length) {
ElMessage.warning("请填写回款明细");
return;
}
if (items.some((it) => !(Number(it.amount) > 0))) {
ElMessage.warning("请填写正确的回款金额");
return;
}
if (Number(form.plan_type) === 4 && items.some((it) => !it.name)) {
ElMessage.warning("请填写进度名称");
return;
}
if (Number(form.plan_type) === 5 && items.some((it) => !it.name)) {
ElMessage.warning("请填写回款名称");
return;
}
const owner = userOptions.value.find((u) => String(u.id) === String(form.owner_user_id));
const payload = {
contract_id: Number(form.contract_id) || 0,
contract_no: form.contract_no,
contract_name: form.contract_name,
customer_id: Number(form.customer_id) || 0,
customer_name: form.customer_name,
contract_amount: Number(form.contract_amount) || 0,
plan_type: Number(form.plan_type) || 1,
pay_method: Number(form.pay_method) || 1,
remind_days: Number(form.remind_days) || 0,
owner_user_id: form.owner_user_id,
owner_user_name: owner?.name || form.owner_user_name || "",
remark: form.remark,
receipt_url: form.receipt_url,
receipt_name: form.receipt_name,
items,
};
saving.value = true;
try {
if (internalId.value) {
await updatePayback(internalId.value, payload);
ElMessage.success("更新成功");
} else {
await createPayback(payload);
ElMessage.success("创建成功");
}
emit("success");
handleClose();
} catch (e) {
ElMessage.error(e.message || "操作失败");
} finally {
saving.value = false;
}
}
function handleClose() {
emit("update:visible", false);
}
function handleClosed() {
formRef.value?.resetFields();
contractOptions.value = [];
Object.assign(form, defaultForm());
}
</script>
<style lang="less" scoped>
.tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: 8px;
}
/* 流水回执上传 */
.receipt-block {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
.receipt-file {
display: flex;
align-items: center;
gap: 8px;
.receipt-name {
margin-left: 4px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.total-amount {
font-size: 16px;
font-weight: 700;
color: var(--el-color-primary);
}
.detail-table {
margin: 0 16px;
width: calc(100% - 32px);
}
/* 进度金额:输入框 + 重算按钮 */
.amount-cell {
display: flex;
align-items: center;
gap: 6px;
.amount-input {
flex: 1;
min-width: 0;
}
}
.col-tip {
margin-left: 4px;
vertical-align: -2px;
color: var(--el-text-color-secondary);
cursor: help;
}
.add-row {
margin: 10px 16px 0;
}
.sum-tip {
margin-left: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.option-name {
float: left;
}
.option-sub {
float: right;
margin-left: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,260 @@
<template>
<el-drawer
:model-value="visible"
title="回款计划详情"
size="900px"
:destroy-on-close="true"
@update:model-value="(v) => emit('update:visible', v)"
@open="loadDetail"
>
<div v-loading="loading" class="payback-detail">
<div class="detail-head">
<div class="head-main">
<h3>{{ detail.contract_name || "-" }}</h3>
<!-- <span class="head-sub"></span> -->
</div>
<el-button type="primary" link :icon="Edit" @click="emit('edit', detail)">编辑</el-button>
<el-button type="danger" link :icon="Delete" @click="handleDelete" style="margin-left: 0px">删除</el-button>
</div>
<el-descriptions :column="2" border class="desc-block">
<el-descriptions-item label="客户名称">{{ detail.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="合同编号">{{ detail.contract_no || "未关联合同编号" }}</el-descriptions-item>
<el-descriptions-item label="回款状态">
<el-tag :type="paybackStatusTag(detail.status)" size="small">
{{ paybackStatusText(detail.status) }}
</el-tag></el-descriptions-item>
<el-descriptions-item label="回款周期">{{ paybackCycleText(detail.plan_type) }}</el-descriptions-item>
<el-descriptions-item label="回款方式">{{ paybackMethodText(detail.pay_method) }}</el-descriptions-item>
<el-descriptions-item label="合同总金额">¥{{ formatMoney(detail.contract_amount) }}</el-descriptions-item>
<el-descriptions-item label="计划回款总额">
<span class="primary-text">¥{{ formatMoney(detail.total_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="已回款金额">
<span class="success-text">¥{{ formatMoney(detail.received_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="流水回执">
<a v-if="detail.receipt_url" :href="detail.receipt_url" target="_blank" class="primary-text">{{ detail.receipt_name || "查看回执" }}</a>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="提前提醒">
{{ detail.remind_days ? `${detail.remind_days} 天` : "-" }}
</el-descriptions-item>
<el-descriptions-item label="负责人">{{ detail.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<div class="detail-section-title">
<span>回款明细</span>
<span class="section-tip">
共 {{ items.length }} 期,已完成 {{ doneCount }} 期
</span>
</div>
<el-table :data="items" border size="small">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="paybackItemStatusTag(row.status)" size="small">
{{ paybackItemStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="明细名称" min-width="130" show-overflow-tooltip />
<el-table-column label="计划日期" width="110" align="center">
<template #default="{ row }">{{ row.plan_date || "-" }}</template>
</el-table-column>
<el-table-column label="回款进度" min-width="190">
<template #default="{ row }">
<div class="progress-cell">
<div class="progress-top">
<span class="success-text">¥{{ formatMoney(row.received_amount) }}</span>
<span class="progress-sep">/</span>
<span>¥{{ formatMoney(row.amount) }}</span>
</div>
<el-progress
:percentage="paybackItemPercent(row)"
:stroke-width="10"
:color="paybackItemPercent(row) >= 100 ? 'var(--el-color-success)' : 'var(--el-color-primary)'"
:show-text="true"
/>
</div>
</template>
</el-table-column>
<el-table-column label="回款日期" width="110" align="center">
<template #default="{ row }">{{ row.received_date || "-" }}</template>
</el-table-column>
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="paybackItemOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(paybackItemOverdue(row).days) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<template #empty><el-empty description="暂无回款明细" :image-size="60" /></template>
</el-table>
</div>
</el-drawer>
</template>
<script setup>
import { ref, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete } from "@element-plus/icons-vue";
import { getPaybackDetail, deletePayback } from "@/api/crmPayback";
import {
paybackCycleText,
paybackMethodText,
paybackStatusText,
paybackStatusTag,
paybackItemStatusText,
paybackItemStatusTag,
paybackPlanOverdue,
paybackItemOverdue,
overdueText,
formatMoney,
} from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
payback: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "edit", "delete"]);
const loading = ref(false);
const detail = ref({});
const items = computed(() => (Array.isArray(detail.value.items) ? detail.value.items : []));
const doneCount = computed(() => items.value.filter((i) => Number(i.status) === 3).length);
/** 回款明细进度百分比(已回款/计划金额,封顶 100 用于进度条显示) */
function paybackItemPercent(row) {
const amount = Number(row.amount) || 0;
const received = Number(row.received_amount) || 0;
if (amount <= 0) return 0;
const pct = Math.round((received / amount) * 100);
return pct > 100 ? 100 : pct;
}
/** 兼容 items 为 JSON 字符串 / 数组两种形态 */
function normalize(row) {
let list = row.items;
if (typeof list === "string" && list.trim()) {
try {
list = JSON.parse(list);
} catch {
list = [];
}
}
return { ...row, items: Array.isArray(list) ? list : [] };
}
async function loadDetail() {
// 先用列表行数据回显,再拉取详情补充完整明细
detail.value = normalize(props.payback || {});
const id = props.payback?.id;
if (!id) return;
loading.value = true;
try {
const res = await getPaybackDetail(id);
detail.value = normalize(res?.data || {});
} catch (e) {
// 详情拉取失败时保留列表行数据
} finally {
loading.value = false;
}
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
`确定删除合同「${detail.value.contract_name}」的回款计划吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deletePayback(detail.value.id);
ElMessage.success("删除成功");
emit("update:visible", false);
emit("delete", detail.value);
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
</script>
<style lang="less" scoped>
.payback-detail {
.detail-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
.head-main {
flex: 1;
display: flex;
flex-direction: column;
h3 {
margin: 0;
font-size: 16px;
}
.head-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
.desc-block {
margin-bottom: 18px;
}
.detail-section-title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
.section-tip {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
}
.primary-text {
color: var(--el-color-primary);
font-weight: 600;
}
.success-text {
color: var(--el-color-success);
font-weight: 600;
}
.progress-cell {
display: flex;
flex-direction: column;
gap: 4px;
.progress-top {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
.progress-sep {
color: var(--el-text-color-placeholder);
}
}
}
}
</style>
+659 -2
View File
@@ -1,5 +1,662 @@
<template>
<div />
<div class="crm-page">
<div class="page-header">
<div>
<h2>回款管理</h2>
<p>针对于合同的回款计划创建与回款进度跟踪</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="refreshAll">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新建回款计划</el-button>
</div>
</div>
<el-tabs v-model="activeTab" class="payback-tabs" @tab-change="handleTabChange">
<el-tab-pane label="回款计划" name="plan" />
<el-tab-pane label="回款进度" name="progress" />
</el-tabs>
<!-- ============================ 回款计划 ============================ -->
<template v-if="activeTab === 'plan'">
<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 primary">¥{{ formatMoney(summary.total_amount) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">已回款金额</span>
<span class="stat-value success">¥{{ formatMoney(summary.received_amount) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">待回款金额</span>
<span class="stat-value warning">¥{{ formatMoney(summary.pending_amount) }}</span>
</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.plan_type" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in PAYBACK_CYCLE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in PAYBACK_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="状态" width="90" align="center" fixed>
<template #default="{ row }">
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="合同名称" min-width="180" show-overflow-tooltip fixed>
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.contract_name || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="客户名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.customer_name || "-" }}</template>
</el-table-column>
<el-table-column label="回款周期" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="回款方式" width="110" align="center">
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
</el-table-column>
<el-table-column label="合同总金额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.contract_amount) }}</template>
</el-table-column>
<el-table-column label="计划回款总额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
</el-table-column>
<el-table-column label="回款进度" width="150" align="center">
<template #default="{ row }">
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
</template>
</el-table-column>
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<el-table-column label="提前提醒" width="90" align="center">
<template #default="{ row }">{{ row.remind_days ? `${row.remind_days}天` : "-" }}</template>
</el-table-column>
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="paybackPlanOverdue(row).overdue" type="danger" size="small" effect="dark">{{ overdueText(paybackPlanOverdue(row).days) }}</el-tag>
<span v-else>-</span>
</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>
</template>
<script setup></script>
<!-- ============================ 回款进度 ============================ -->
<template v-else>
<div class="stat-cards">
<div class="stat-card">
<span class="stat-label">分期总数</span>
<span class="stat-value">{{ progressList.length }}</span>
</div>
<div class="stat-card">
<span class="stat-label">计划回款总额</span>
<span class="stat-value primary">¥{{ formatMoney(progressSummary.total_planned) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">已回款金额</span>
<span class="stat-value success">¥{{ formatMoney(progressSummary.total_received) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">待回款金额</span>
<span class="stat-value warning">
¥{{ formatMoney((progressSummary.total_planned || 0) - (progressSummary.total_received || 0)) }}
</span>
</div>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="progressFilters" @submit.prevent>
<el-form-item>
<el-input
v-model="progressFilters.keyword"
clearable
placeholder="搜索合同 / 客户 / 负责人"
:prefix-icon="Search"
style="width: 240px"
@keyup.enter="fetchProgress"
/>
</el-form-item>
<el-form-item label="回款状态">
<el-select v-model="progressFilters.status" clearable placeholder="全部" style="width: 120px">
<el-option v-for="i in PAYBACK_ITEM_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="fetchProgress">查询</el-button>
<el-button :icon="Refresh" @click="resetProgressFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-container" v-loading="progressLoading">
<el-table :data="pagedProgress" stripe border row-key="rowKey">
<el-table-column label="回款周期" width="90" align="center" fixed>
<template #default="{ row }">{{ paybackCycleText(row.plan_type) }}</template>
</el-table-column>
<el-table-column label="明细" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.name || `第${row.seq}期` }}</template>
</el-table-column>
<el-table-column label="进度百分比" width="100" align="center">
<template #default="{ row }">{{ row.percent ? `${row.percent}%` : "-" }}</template>
</el-table-column>
<el-table-column label="计划金额" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="计划日期" width="110" align="center">
<template #default="{ row }">{{ row.plan_date || "-" }}</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
</el-table-column>
<el-table-column label="回款日期" width="110" align="center">
<template #default="{ row }">{{ (row.received_date && row.received_date !== "<nil>") ? row.received_date : "-" }}</template>
</el-table-column>
<el-table-column label="合同名称" min-width="180" show-overflow-tooltip >
<template #default="{ row }">{{ row.contract_name || "-" }}</template>
</el-table-column>
<el-table-column label="客户名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.customer_name || "-" }}</template>
</el-table-column>
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="paybackItemOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(paybackItemOverdue(row).days) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="paybackItemStatusTag(row.status)" size="small">
{{ paybackItemStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="流水回执" width="110" align="center">
<template #default="{ row }">
<el-link v-if="row.receipt_url" type="primary" :href="row.receipt_url" target="_blank" :underline="false">查看回执</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" class-name="op-col" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openReceive(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="progressPagination.page"
v-model:page-size="progressPagination.pageSize"
:total="progressList.length"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
/>
</div>
</div>
</template>
<!-- 新建 / 编辑回款计划 -->
<PaybackCreate
v-model:visible="createVisible"
:edit-data="currentRow"
@success="refreshAll"
/>
<!-- 回款计划详情 -->
<PaybackDetail
v-model:visible="detailVisible"
:payback="currentRow"
@edit="handleDetailEdit"
@delete="handleDetailDelete"
/>
<!-- 回款登记 -->
<el-dialog v-model="receiveVisible" title="回款登记" width="440px">
<el-form :model="receiveForm" label-width="90px">
<el-form-item label="合同名称">
<span>{{ receiveForm.contract_name || "-" }}</span>
</el-form-item>
<el-form-item label="明细">
<span>{{ receiveForm.name || `第${receiveForm.seq}期` }}</span>
</el-form-item>
<el-form-item label="计划金额">
<span class="amount">¥{{ formatMoney(receiveForm.amount) }}</span>
</el-form-item>
<el-form-item label="实收金额">
<el-input-number
v-model="receiveForm.received_amount"
:min="0"
:precision="2"
:controls="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="receiveForm.received_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="流水回执">
<div class="receipt-block">
<el-upload
:show-file-list="false"
:http-request="handleReceiptUpload"
:disabled="uploading"
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.xls,.xlsx"
>
<el-button :icon="Upload" :loading="uploading" size="small">上传流水回执</el-button>
</el-upload>
<div v-if="receiveForm.receipt_url" class="receipt-file">
<el-link type="primary" :href="receiveForm.receipt_url" target="_blank" :underline="false">
<el-icon><Document /></el-icon>
<span class="receipt-name">{{ receiveForm.receipt_name || "流水回执" }}</span>
</el-link>
<el-button link type="danger" size="small" :icon="Delete" @click="clearReceipt">移除</el-button>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="receiveVisible = false">取消</el-button>
<el-button type="primary" :loading="receiveSaving" @click="submitReceive">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh, Upload, Document, Delete } from "@element-plus/icons-vue";
import {
getPaybackList,
deletePayback,
getPaybackProgress,
registerPaybackReceive,
} from "@/api/crmPayback";
import { uploadFile } from "@/api/file";
import PaybackCreate from "./components/create.vue";
import PaybackDetail from "./components/detail.vue";
import {
PAYBACK_CYCLE_OPTIONS,
PAYBACK_STATUS_OPTIONS,
PAYBACK_ITEM_STATUS_OPTIONS,
paybackCycleText,
paybackMethodText,
paybackStatusText,
paybackStatusTag,
paybackItemStatusText,
paybackItemStatusTag,
paybackPlanOverdue,
paybackItemOverdue,
overdueText,
formatMoney,
} from "../dict";
const activeTab = ref("plan");
/* ---------------------------- 回款计划 ---------------------------- */
const loading = ref(false);
const tableData = ref([]);
const createVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
const summary = ref({ total_amount: 0, received_amount: 0, pending_amount: 0 });
const filters = reactive({ keyword: "", plan_type: "", status: "" });
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
async function fetchList() {
loading.value = true;
try {
const res = await getPaybackList({
page: pagination.page,
pageSize: pagination.pageSize,
...filters,
});
tableData.value = res?.data?.list || [];
pagination.total = res?.data?.total || 0;
summary.value = res?.data?.summary || { total_amount: 0, received_amount: 0, pending_amount: 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.plan_type = "";
filters.status = "";
handleSearch();
}
/** 回款进度百分比(已回款 / 计划总额) */
function paybackPercent(row) {
const total = Number(row.total_amount) || 0;
if (!total) return 0;
const p = (Number(row.received_amount) / total) * 100;
return Math.min(100, Math.round(p));
}
/* ---------------------------- 回款进度 ---------------------------- */
const progressLoading = ref(false);
const progressList = ref([]);
const progressSummary = ref({ total_planned: 0, total_received: 0 });
const progressFilters = reactive({ keyword: "", status: "" });
const progressPagination = reactive({ page: 1, pageSize: 20 });
const pagedProgress = computed(() => {
const start = (progressPagination.page - 1) * progressPagination.pageSize;
return progressList.value.slice(start, start + progressPagination.pageSize).map((r) => ({
...r,
rowKey: `${r.payback_id}_${r.seq}`,
}));
});
async function fetchProgress() {
progressLoading.value = true;
try {
const res = await getPaybackProgress({ ...progressFilters });
progressList.value = (res?.data?.list || []).map((r) => ({ ...r, rowKey: `${r.payback_id}_${r.seq}` }));
progressSummary.value = {
total_planned: res?.data?.total_planned || 0,
total_received: res?.data?.total_received || 0,
};
progressPagination.page = 1;
} catch (e) {
progressList.value = [];
progressSummary.value = { total_planned: 0, total_received: 0 };
ElMessage.error(e.message || "查询失败");
} finally {
progressLoading.value = false;
}
}
function resetProgressFilters() {
progressFilters.keyword = "";
progressFilters.status = "";
fetchProgress();
}
/* ---------------------------- 回款登记 ---------------------------- */
const receiveVisible = ref(false);
const receiveSaving = ref(false);
const receiveForm = reactive({
payback_id: null,
seq: 0,
name: "",
contract_name: "",
amount: 0,
received_amount: 0,
received_date: "",
receipt_url: "",
receipt_name: "",
});
function openReceive(row) {
Object.assign(receiveForm, {
payback_id: row.payback_id,
seq: row.seq,
name: row.name,
contract_name: row.contract_name,
amount: row.amount,
received_amount: Number(row.received_amount) || 0,
received_date: (row.received_date && row.received_date !== "<nil>") ? row.received_date : "",
receipt_url: row.receipt_url || "",
receipt_name: row.receipt_name || "",
});
receiveVisible.value = true;
}
/** 上传流水回执(非必填):复用通用文件上传接口 */
const uploading = ref(false);
async function handleReceiptUpload(options) {
const file = options.file;
uploading.value = true;
try {
const fd = new FormData();
fd.append("file", file);
const res = await uploadFile(fd);
const data = res?.data || {};
receiveForm.receipt_url = data.url || "";
receiveForm.receipt_name = data.name || file.name;
ElMessage.success("上传成功");
} catch (e) {
ElMessage.error(e.message || "上传失败");
} finally {
uploading.value = false;
}
}
function clearReceipt() {
receiveForm.receipt_url = "";
receiveForm.receipt_name = "";
}
async function submitReceive() {
receiveSaving.value = true;
try {
await registerPaybackReceive(receiveForm.payback_id, {
seq: receiveForm.seq,
received_amount: Number(receiveForm.received_amount) || 0,
received_date: receiveForm.received_date || "",
receipt_url: receiveForm.receipt_url,
receipt_name: receiveForm.receipt_name,
});
ElMessage.success("回款登记成功");
receiveVisible.value = false;
fetchProgress();
} catch (e) {
ElMessage.error(e.message || "登记失败");
} finally {
receiveSaving.value = false;
}
}
/* ---------------------------- 通用操作 ---------------------------- */
function openCreate() {
currentRow.value = null;
createVisible.value = true;
}
function openEdit(row) {
currentRow.value = { ...row };
createVisible.value = true;
}
function openDetail(row) {
currentRow.value = { ...row };
detailVisible.value = true;
}
/** 详情内点击编辑:关闭详情并打开编辑弹窗 */
function handleDetailEdit(row) {
detailVisible.value = false;
openEdit(row);
}
/** 详情内点击删除:删除回款计划并刷新列表 */
async function handleDetailDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除合同「${row.contract_name}」的回款计划吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deletePayback(row.id);
ElMessage.success("删除成功");
detailVisible.value = false;
refreshAll();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
function handleTabChange(name) {
if (name === "progress") fetchProgress();
else fetchList();
}
function refreshAll() {
if (activeTab.value === "progress") fetchProgress();
else fetchList();
}
onMounted(fetchList);
</script>
<style lang="less" scoped src="../styles/crm-page.less"></style>
<style lang="less" scoped>
.payback-tabs {
margin-bottom: 4px;
:deep(.el-tabs__header) {
margin-bottom: 12px;
}
}
.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);
}
&.warning {
color: var(--el-color-warning);
}
}
}
}
.amount {
font-weight: 700;
color: var(--el-color-primary);
}
/* 流水回执上传 */
.receipt-block {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
.receipt-file {
display: flex;
align-items: center;
gap: 8px;
.receipt-name {
margin-left: 4px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
::deep(.el-table .op-col .cell) {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 4px;
}
</style>
@@ -19,6 +19,21 @@
<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="line_type">
<el-select v-model="form.line_type" placeholder="请选择" style="width: 100%" @change="onLineTypeChange">
<el-option label="成品" value="product" />
<el-option label="软件开发" value="dev" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.line_type === 'dev'">
<el-form-item label="统一人天单价" prop="dev_unit_price">
<el-input-number v-model="form.dev_unit_price" :min="0" :precision="2" :controls="false" style="width: 100%">
<template #prefix>¥</template>
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品分类" prop="category">
<el-select
@@ -45,21 +60,29 @@
<el-input v-model="form.spec" placeholder="如:型号 / 配置 / 功能模块" maxlength="255" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="24" v-if="form.line_type === 'dev'">
<el-form-item label="模块报价" prop="dev_tree">
<el-button type="primary" link @click="devVisible = true">
📁 编辑模块树({{ (form.dev_tree || []).length }} 项 · ¥{{ formatMoney(treeSellTotal(form.dev_tree, form.dev_unit_price)) }})
</el-button>
<div class="dev-tip">叶子模块只填「人天」,人天单价统一;父级自动汇总金额</div>
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.line_type === 'product'">
<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-col :span="8" v-if="form.line_type === 'product'">
<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-col :span="8" v-if="form.line_type === 'product'">
<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>
@@ -78,6 +101,15 @@
</el-col>
</el-row>
</el-form>
<DevModuleTree
v-model="form.dev_tree"
:unit-price="form.dev_unit_price"
v-model:visible="devVisible"
@update:unit-price="(v) => (form.dev_unit_price = v)"
:title="form.product_name ? form.product_name + ' - 模块报价' : '软件开发模块报价'"
/>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
@@ -90,7 +122,9 @@ 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";
import { PRODUCT_STATUS_OPTIONS, PRODUCT_UNIT_OPTIONS, formatMoney } from "../../dict";
import { treeSellTotal } from "../../contract/components/utils";
import DevModuleTree from "../../contract/components/DevModuleTree.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -103,6 +137,7 @@ const submitting = ref(false);
const isEdit = ref(false);
const internalId = ref(null);
const categoryOptions = ref([]);
const devVisible = ref(false);
const defaultForm = () => ({
product_name: "",
@@ -110,9 +145,12 @@ const defaultForm = () => ({
category: "",
unit: "",
spec: "",
line_type: "product",
price: 0,
cost_price: 0,
tax_rate: 0,
dev_unit_price: 0,
dev_tree: [],
status: 1,
remark: "",
});
@@ -123,6 +161,28 @@ const rules = {
product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
};
/** 切换行类型:软件开发确保有模块树容器 */
function onLineTypeChange() {
if (form.line_type === "dev") {
if (!Array.isArray(form.dev_tree)) form.dev_tree = [];
if (form.dev_unit_price == null) form.dev_unit_price = 0;
}
}
/** 安全解析产品管理返回的 dev_tree(可能为字符串或数组) */
function normalizeDevTree(v) {
if (Array.isArray(v)) return v;
if (typeof v === "string" && v) {
try {
const parsed = JSON.parse(v);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
return [];
}
watch(
() => props.visible,
(val) => {
@@ -133,6 +193,9 @@ watch(
Object.assign(form, defaultForm(), props.editData);
form.status = Number(props.editData.status);
if (isNaN(form.status)) form.status = 1;
form.line_type = props.editData.line_type === "dev" ? "dev" : "product";
form.dev_unit_price = Number(props.editData.dev_unit_price) || 0;
form.dev_tree = normalizeDevTree(props.editData.dev_tree);
} else {
isEdit.value = false;
internalId.value = null;
@@ -8,6 +8,18 @@
@opened="loadAll"
>
<div v-if="project" class="project-detail">
<div class="detail-toolbar">
<div class="toolbar-status">
<el-tag :type="projectStatusTag(project.status)" size="small">{{ projectStatusText(project.status) }}</el-tag>
<el-tag v-if="projectOverdue(project).overdue" type="danger" size="small" effect="dark">
{{ overdueText(projectOverdue(project).days) }}
</el-tag>
</div>
<div class="toolbar-actions">
<el-button type="primary" size="small" :icon="Edit" @click="handleEdit">编辑</el-button>
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
</div>
</div>
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="basic">
@@ -80,6 +92,9 @@
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete } from "@element-plus/icons-vue";
import { deleteProject } from "@/api/crmPipeline";
import DetailBasic from "./detail_basic.vue";
import DetailContact from "./detail_contact.vue";
import DetailFollow from "./detail_follow.vue";
@@ -87,12 +102,13 @@ import DetailContract from "./detail_contract.vue";
import DetailAttach from "./detail_attach.vue";
import DetailDocs from "./detail_docs.vue";
import DetailLog from "./detail_log.vue";
import { projectStatusText, projectStatusTag, projectOverdue, overdueText } from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
project: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "refresh"]);
const emit = defineEmits(["update:visible", "refresh", "edit", "delete"]);
// 关联类型:3=项目
const RELATED_TYPE = 3;
@@ -125,6 +141,24 @@ function handleClose() {
emit("update:visible", false);
}
function handleEdit() {
emit("edit", props.project);
}
async function handleDelete() {
try {
await ElMessageBox.confirm(`确定删除项目「${props.project.project_name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
});
await deleteProject(props.project.id);
ElMessage.success("删除成功");
emit("update:visible", false);
emit("delete");
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
/** 抽屉打开后刷新各 Tab 数据 */
function loadAll() {
if (!props.project?.id) return;
@@ -145,6 +179,27 @@ defineExpose({ loadAll });
}
.project-detail {
.detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 12px;
margin-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
.toolbar-status {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.detail-tabs {
// 横向 flex:导航固定宽 + 内容区占满剩余宽度
display: flex;
@@ -1,15 +1,73 @@
<template>
<div class="detail-attach">
<div class="tab-toolbar">
<div class="toolbar-left">
<el-upload :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
</el-upload>
<span v-if="tip" class="tip">{{ tip }}</span>
</div>
<el-table :data="list" v-loading="loading" stripe border size="small">
<el-radio-group v-model="viewMode" size="small" class="view-switch">
<el-radio-button value="grid">
<el-icon><Grid /></el-icon>
</el-radio-button>
<el-radio-button value="list">
<el-icon><Menu /></el-icon>
</el-radio-button>
</el-radio-group>
</div>
<!-- 图标模式 -->
<div v-if="viewMode === 'grid'" v-loading="loading" class="attach-grid">
<div
v-for="row in list"
:key="row.id"
class="attach-card"
@click="openPreview(row)"
>
<div class="attach-icon" :class="fileKind(row)">
<el-icon :size="32"><component :is="fileIcon(row)" /></el-icon>
</div>
<div class="attach-name" :title="row.file_name || '-'">
{{ row.file_name || "-" }}
</div>
<div class="attach-meta">
<span class="attach-size">{{ formatFileSize(row.file_size) }}</span>
<el-tag
v-if="showStage"
size="small"
effect="plain"
:type="pipelineStageTag(row.related_type)"
>
{{ pipelineStageText(row.related_type) }}
</el-tag>
</div>
<div class="attach-sub">
<span class="attach-user" :title="row.uploader_name">{{ row.uploader_name || "-" }}</span>
<span class="attach-time">{{ formatDateTime(row.create_time) }}</span>
</div>
<el-button
class="attach-del"
link
type="danger"
size="small"
:icon="Delete"
@click.stop="handleDelete(row)"
/>
</div>
<el-empty
v-if="!list.length && !loading"
description="暂无附件"
:image-size="60"
class="attach-empty"
/>
</div>
<!-- 列表模式 -->
<el-table v-else :data="list" v-loading="loading" stripe border size="small">
<el-table-column label="文件名" min-width="170" show-overflow-tooltip>
<template #default="{ row }">
<a :href="row.file_url" target="_blank" class="file-link">{{ row.file_name || "-" }}</a>
<span class="file-link" @click="openPreview(row)">{{ row.file_name || "-" }}</span>
</template>
</el-table-column>
<el-table-column v-if="showStage" label="阶段" min-width="62" align="center">
@@ -26,20 +84,120 @@
<el-table-column label="上传时间" min-width="140">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="76" align="center">
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openPreview(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="60" /></template>
</el-table>
<!-- 分页 -->
<div v-if="total > 0" class="attach-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 40, 60, 100]"
layout="total, sizes, prev, pager, next"
background
size="small"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
<!-- 在线预览 -->
<el-dialog
v-model="previewVisible"
:title="previewRow?.file_name || '附件预览'"
width="900px"
top="6vh"
append-to-body
destroy-on-close
class="attach-preview-dialog"
>
<div class="preview-body">
<div
v-if="previewType === 'image'"
class="preview-zoom"
:class="{ 'is-dragging': dragging }"
@wheel.prevent="onWheel"
@mousedown="onDragStart"
@mousemove="onDragMove"
@mouseup="onDragEnd"
@mouseleave="onDragEnd"
>
<img
:src="previewRow.file_url"
class="preview-zoom-img"
:style="imgStyle"
alt=""
draggable="false"
/>
<div class="preview-zoom-bar" @mousedown.stop @wheel.stop>
<el-button link size="small" :icon="ZoomOut" @click="stepZoom(-0.2)" />
<span class="zoom-text">{{ Math.round(zoom * 100) }}%</span>
<el-button link size="small" :icon="ZoomIn" @click="stepZoom(0.2)" />
<el-button link size="small" :icon="RefreshRight" @click="resetZoom" />
</div>
</div>
<video
v-else-if="previewType === 'video'"
:src="previewRow.file_url"
controls
class="preview-video"
></video>
<audio
v-else-if="previewType === 'audio'"
:src="previewRow.file_url"
controls
class="preview-audio"
></audio>
<iframe
v-else-if="previewType === 'pdf'"
:src="previewRow.file_url"
class="preview-frame"
></iframe>
<iframe v-else-if="previewType === 'office'" :src="officeUrl" class="preview-frame"></iframe>
<iframe
v-else-if="previewType === 'frame'"
:src="previewRow.file_url"
class="preview-frame"
></iframe>
<el-empty
v-else
description="该文件类型暂不支持在线预览,请下载后查看"
:image-size="80"
/>
</div>
<template #footer>
<el-button :icon="Download" @click="downloadFile(previewRow)">下载</el-button>
<el-button type="primary" @click="previewVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from "vue";
import { computed, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Upload } from "@element-plus/icons-vue";
import {
Upload,
Grid,
Menu,
Delete,
Download,
ZoomIn,
ZoomOut,
RefreshRight,
Document,
Picture,
VideoCamera,
Headset,
FolderOpened,
} from "@element-plus/icons-vue";
import { getAttachList, addAttach, deleteAttach } from "@/api/crmPipeline";
import { uploadFile } from "@/api/file";
import { formatDateTime, pipelineStageText, pipelineStageTag } from "../../dict";
@@ -61,11 +219,82 @@ const emit = defineEmits(["count"]);
const loading = ref(false);
const uploading = ref(false);
const list = ref([]);
// 视图模式:grid=图标模式(默认,便于快速查找) / list=列表模式
const viewMode = ref("grid");
// 分页:默认每页 20 条
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
// 预览
const previewVisible = ref(false);
const previewRow = ref({});
// 图片缩放/拖拽
const zoom = ref(1);
const panX = ref(0);
const panY = ref(0);
const dragging = ref(false);
const dragStart = { x: 0, y: 0, px: 0, py: 0 };
const ZOOM_MIN = 0.2;
const ZOOM_MAX = 8;
const imgStyle = computed(() => ({
transform: `translate(${panX.value}px, ${panY.value}px) scale(${zoom.value})`,
}));
function clampPan() {
// 未放大时不允许拖动偏移
if (zoom.value <= 1) {
panX.value = 0;
panY.value = 0;
}
}
function setZoom(v) {
zoom.value = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Number(v.toFixed(2))));
clampPan();
}
function stepZoom(delta) {
setZoom(zoom.value + delta);
}
function resetZoom() {
zoom.value = 1;
panX.value = 0;
panY.value = 0;
}
/** 滚轮缩放(向上放大、向下缩小) */
function onWheel(e) {
const factor = e.deltaY < 0 ? 1.12 : 0.89;
setZoom(zoom.value * factor);
}
function onDragStart(e) {
if (zoom.value <= 1) return;
dragging.value = true;
dragStart.x = e.clientX;
dragStart.y = e.clientY;
dragStart.px = panX.value;
dragStart.py = panY.value;
}
function onDragMove(e) {
if (!dragging.value) return;
panX.value = dragStart.px + (e.clientX - dragStart.x);
panY.value = dragStart.py + (e.clientY - dragStart.y);
}
function onDragEnd() {
dragging.value = false;
}
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
if (!props.relatedId) {
list.value = [];
total.value = 0;
emit("count", 0);
return;
}
@@ -74,16 +303,41 @@ async function reload() {
const params = {
related_type: props.relatedType,
related_id: props.relatedId,
page: page.value,
pageSize: pageSize.value,
};
if (props.includeSource) params.include_source = 1;
const res = await getAttachList(params);
list.value = res?.data?.list || [];
const data = res?.data || {};
const rawList = data.list || [];
total.value = Number(data.total) || rawList.length;
// 兜底:后端若未按分页返回(条数超过每页),前端本地切片,保证一页数量正确
if (rawList.length > pageSize.value) {
const start = (page.value - 1) * pageSize.value;
list.value = rawList.slice(start, start + pageSize.value);
} else {
list.value = rawList;
}
} catch (e) {
list.value = [];
total.value = 0;
} finally {
loading.value = false;
}
emit("count", list.value.length);
emit("count", total.value);
}
/** 切换页码 */
function handlePageChange(p) {
page.value = p;
reload();
}
/** 切换每页条数(回到第一页) */
function handleSizeChange(s) {
pageSize.value = s;
page.value = 1;
reload();
}
async function handleUpload(options) {
@@ -103,6 +357,7 @@ async function handleUpload(options) {
file_size: file.size || 0,
});
ElMessage.success("上传成功");
page.value = 1;
reload();
} catch (e) {
ElMessage.error(e.message || "上传失败");
@@ -116,6 +371,8 @@ async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除附件「${row.file_name}」吗?`, "删除确认", { type: "warning" });
await deleteAttach({ id: row.id });
ElMessage.success("删除成功");
// 删除当前页最后一条时回退一页,避免出现空白页
if (list.value.length === 1 && page.value > 1) page.value -= 1;
reload();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
@@ -129,6 +386,101 @@ function formatFileSize(size) {
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
/** 取文件扩展名(小写,不含点) */
function extOf(name) {
const n = String(name || "");
const i = n.lastIndexOf(".");
return i > -1 ? n.slice(i + 1).toLowerCase() : "";
}
const IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico"];
const VIDEO_EXTS = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"];
const AUDIO_EXTS = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"];
const ARCHIVE_EXTS = ["zip", "rar", "7z", "tar", "gz", "bz2"];
const OFFICE_EXTS = ["doc", "docx", "xls", "xlsx", "ppt", "pptx"];
const FRAME_EXTS = [
"txt",
"md",
"json",
"log",
"xml",
"html",
"htm",
"css",
"js",
"ts",
"csv",
"yml",
"yaml",
"ini",
"conf",
];
/** 按扩展名归类,用于图标配色 */
function fileKind(row) {
const ext = extOf(row.file_name || row.file_url);
if (IMAGE_EXTS.includes(ext)) return "is-image";
if (VIDEO_EXTS.includes(ext)) return "is-video";
if (AUDIO_EXTS.includes(ext)) return "is-audio";
if (ARCHIVE_EXTS.includes(ext)) return "is-archive";
if (ext === "pdf") return "is-pdf";
if (["doc", "docx"].includes(ext)) return "is-word";
if (["xls", "xlsx", "csv"].includes(ext)) return "is-excel";
if (["ppt", "pptx"].includes(ext)) return "is-ppt";
return "is-file";
}
/** 按扩展名返回文件图标组件 */
function fileIcon(row) {
switch (fileKind(row)) {
case "is-image":
return Picture;
case "is-video":
return VideoCamera;
case "is-audio":
return Headset;
case "is-archive":
return FolderOpened;
default:
return Document;
}
}
/** 预览类型:image / video / audio / pdf / office / frame(可 iframe 直出)/ none */
function previewTypeOf(row) {
const ext = extOf(row.file_name || row.file_url);
if (IMAGE_EXTS.includes(ext)) return "image";
if (VIDEO_EXTS.includes(ext)) return "video";
if (AUDIO_EXTS.includes(ext)) return "audio";
if (ext === "pdf") return "pdf";
if (OFFICE_EXTS.includes(ext)) return "office";
if (FRAME_EXTS.includes(ext)) return "frame";
return "none";
}
const previewType = computed(() =>
previewRow.value ? previewTypeOf(previewRow.value) : "none"
);
// Office 文档通过微软在线预览服务渲染(需要文件 URL 可被公网访问)
const officeUrl = computed(() => {
const url = previewRow.value?.file_url || "";
if (!url) return "";
return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`;
});
/** 打开在线预览 */
function openPreview(row) {
previewRow.value = row || {};
resetZoom();
previewVisible.value = true;
}
/** 下载文件(新窗口打开原始地址) */
function downloadFile(row) {
if (row?.file_url) window.open(row.file_url, "_blank");
}
defineExpose({ reload });
</script>
@@ -136,21 +488,241 @@ defineExpose({ reload });
.tab-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.view-switch {
:deep(.el-radio-button__inner) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
}
}
}
.file-link {
color: var(--el-color-primary);
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
/* 图标模式网格 */
.attach-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 12px;
min-height: 120px;
.attach-empty {
grid-column: 1 / -1;
}
}
.attach-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 12px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-bg-color);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
.attach-del {
opacity: 1;
}
}
.attach-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 10px;
color: #fff;
background: var(--el-text-color-placeholder);
&.is-image {
background: #10b981;
}
&.is-video {
background: #8b5cf6;
}
&.is-audio {
background: #14b8a6;
}
&.is-archive {
background: #f59e0b;
}
&.is-pdf {
background: #ef4444;
}
&.is-word {
background: #3b82f6;
}
&.is-excel {
background: #16a34a;
}
&.is-ppt {
background: #f97316;
}
}
.attach-name {
width: 100%;
font-size: 13px;
color: var(--el-text-color-primary);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attach-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.attach-sub {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
font-size: 11px;
color: var(--el-text-color-placeholder);
.attach-user,
.attach-time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.attach-del {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 0.2s;
}
}
/* 分页 */
.attach-pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
/* 在线预览 */
.preview-body {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
max-height: 74vh;
overflow: auto;
.preview-zoom {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 72vh;
overflow: hidden;
background: var(--el-fill-color-lighter);
cursor: grab;
&.is-dragging {
cursor: grabbing;
}
.preview-zoom-img {
max-width: 100%;
max-height: 100%;
user-select: none;
transform-origin: center center;
transition: transform 0.06s linear;
will-change: transform;
}
.preview-zoom-bar {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
.zoom-text {
min-width: 42px;
font-size: 12px;
text-align: center;
}
:deep(.el-button) {
color: #fff;
margin-left: 0;
}
}
}
.preview-video {
max-width: 100%;
max-height: 72vh;
}
.preview-audio {
width: 100%;
}
.preview-frame {
width: 100%;
height: 72vh;
border: 0;
}
}
</style>
@@ -26,17 +26,17 @@
<div class="pane-head">
<div class="pane-title">
<span class="name">{{ c.contract_name }}</span>
<el-tag :type="contractStatusTag(c.status)" size="small">
<!-- <el-tag :type="contractStatusTag(c.status)" size="small">
{{ contractStatusText(c.status) }}
</el-tag>
</el-tag> -->
<el-tag type="primary" size="small" effect="plain">
{{ ourRoleText(c.our_role) }}
</el-tag>
</div>
<div class="pane-actions">
<!-- <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>
<div class="pane-sub">
{{ c.contract_no || "-" }} · {{ contractCategoryText(c.contract_category) }}
+28 -34
View File
@@ -37,7 +37,12 @@
<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>
<el-table-column label="状态" width="100" align="center" fixed>
<template #default="{ row }">
<el-tag :type="projectStatusTag(row.status)" size="small">{{ projectStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="项目名称" min-width="180" show-overflow-tooltip >
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.project_name }}</span>
</template>
@@ -47,9 +52,12 @@
<el-table-column label="项目金额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag :type="projectStatusTag(row.status)" size="small">{{ projectStatusText(row.status) }}</el-tag>
<el-tag v-if="projectOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(projectOverdue(row).days) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="开始日期" width="120" align="center">
@@ -64,21 +72,6 @@
<el-table-column label="对接人" width="100">
<template #default="{ row }">{{ row.contact_person || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="210" align="center" fixed="right">
<template #default="{ row }">
<!-- <el-button
link
type="primary"
size="small"
:disabled="!row.customer_id"
@click="openContactBook(row)"
>
通讯录
</el-button> -->
<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>
@@ -105,15 +98,21 @@
:company-name="contactBookCompanyName"
/>
<ProjectDetail v-model:visible="detailVisible" :project="currentRow" @refresh="fetchList" />
<ProjectDetail
v-model:visible="detailVisible"
:project="currentRow"
@refresh="fetchList"
@edit="onDetailEdit"
@delete="fetchList"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import { getProjectList, deleteProject } from "@/api/crmPipeline";
import { getProjectList } from "@/api/crmPipeline";
import ProjectEdit from "./components/edit.vue";
import ProjectDetail from "./components/detail.vue";
import ContactBook from "../../erp/components/contactBook.vue";
@@ -121,6 +120,8 @@ import {
PROJECT_STATUS_OPTIONS,
projectStatusText,
projectStatusTag,
projectOverdue,
overdueText,
formatMoney,
formatDateOnly,
} from "../dict";
@@ -188,6 +189,12 @@ function openDetail(row) {
detailVisible.value = true;
}
/** 详情内触发编辑:关闭详情抽屉,打开编辑弹窗 */
function onDetailEdit(row) {
detailVisible.value = false;
openEdit(row || currentRow.value);
}
function openContactBook(row) {
if (!row.customer_id) {
ElMessage.warning("该项目未关联正式客户,暂无正式联系人");
@@ -197,19 +204,6 @@ function openContactBook(row) {
contactBookCompanyName.value = row.customer_name || "";
contactBookVisible.value = true;
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定删除项目「${row.project_name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
});
await deleteProject(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>
@@ -7,13 +7,13 @@
@update:model-value="handleClose"
@closed="handleClosed"
>
<!-- 步骤条 -->
<!-- 步骤条(支持点击步骤直接跳转) -->
<div class="steps-wrapper">
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="基本信息" />
<el-step title="联系信息" />
<el-step title="开票信息" />
<el-step title="备注信息" />
<el-steps :active="activeStep" finish-status="success" align-center class="clickable-steps">
<el-step title="基本信息" class="step-clickable" @click="goStep(0)" />
<el-step title="联系信息" class="step-clickable" @click="goStep(1)" />
<el-step title="开票信息" class="step-clickable" @click="goStep(2)" />
<el-step title="备注信息" class="step-clickable" @click="goStep(3)" />
</el-steps>
</div>
@@ -382,6 +382,13 @@ function prevStep() {
}
}
/** 点击上方步骤条直接跳转(无需按上一步 / 下一步) */
function goStep(index) {
const target = Math.max(0, Math.min(3, Number(index) || 0));
if (target === activeStep.value) return;
activeStep.value = target;
}
async function handleSave(isDraft) {
if (!formRef.value) return;
if (isDraft) {
@@ -441,6 +448,15 @@ function handleSaveDraft() {
padding: 0 20px;
}
/* 步骤条可点击跳转 */
.clickable-steps {
:deep(.step-clickable),
:deep(.el-step__head),
:deep(.el-step__main) {
cursor: pointer;
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
+114 -3
View File
@@ -47,6 +47,7 @@ type contractPayload struct {
SignDate string `json:"sign_date"`
EffectiveDate string `json:"effective_date"`
ExpireDate string `json:"expire_date"`
TechDate string `json:"tech_date"`
Parties json.RawMessage `json:"parties"`
Products json.RawMessage `json:"products"`
Step int8 `json:"step"`
@@ -126,6 +127,7 @@ func (c *BackendCrmContractController) List() {
}
items := make([]contractResp, 0, len(list))
for i := range list {
c.syncContractExceptionStatus(&list[i])
items = append(items, buildContractResp(&list[i]))
}
pipelineOk(&c.Controller, map[string]interface{}{
@@ -135,6 +137,8 @@ func (c *BackendCrmContractController) List() {
// Stats GET /backend/crm/contract/stats
// 全租户合同统计(排除已作废),供列表页顶部汇总卡片使用。
// 支持按周期筛选:period=month/quarter/year(当期)或 custom(start_date~end_date),
// 按合同有效期 [生效日期, 结束日期] 与所选周期重叠统计。
func (c *BackendCrmContractController) Stats() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
@@ -142,13 +146,34 @@ func (c *BackendCrmContractController) Stats() {
return
}
tenantID := pipelineTenantID(claims)
period := strings.TrimSpace(c.GetString("period"))
start := strings.TrimSpace(c.GetString("start_date"))
end := strings.TrimSpace(c.GetString("end_date"))
if period != "" && period != "custom" {
start, end = periodRange(period)
}
table := new(models.TenantCrmContract).TableName()
where := "tenant_id = ? AND delete_time IS NULL AND status <> 3"
args := []interface{}{tenantID}
// 按合同有效期 [生效日期, 结束日期] 与所选周期 [start, end] 重叠来统计:
// 合同生效日 <= 周期末 且 合同结束日 >= 周期初;日期为空视为不限制对应边界。
if start != "" {
where += " AND (expire_date IS NULL OR expire_date >= ?)"
args = append(args, start)
}
if end != "" {
where += " AND (effective_date IS NULL OR effective_date <= ?)"
args = append(args, end)
}
raw := "SELECT COUNT(*) AS total, IFNULL(SUM(total_amount),0) AS total_amount, " +
"IFNULL(SUM(total_cost),0) AS total_cost, IFNULL(SUM(total_profit),0) AS total_profit " +
"FROM " + table + " WHERE tenant_id = ? AND delete_time IS NULL AND status <> 3"
"FROM " + table + " WHERE " + where
var rows []orm.Params
if _, err := models.Orm.Raw(raw, tenantID).Values(&rows); err != nil || len(rows) == 0 {
pipelineOk(&c.Controller, contractSummary{TotalProfit: 0})
if _, err := models.Orm.Raw(raw, args...).Values(&rows); err != nil || len(rows) == 0 {
pipelineOk(&c.Controller, map[string]interface{}{
"total": 0, "total_amount": 0, "total_cost": 0, "total_profit": 0,
})
return
}
r := rows[0]
@@ -160,6 +185,28 @@ func (c *BackendCrmContractController) Stats() {
})
}
// periodRange 根据 period 返回 [start, end] 签约日期区间(YYYY-MM-DD):month/quarter/year 取当期。
func periodRange(period string) (string, string) {
now := time.Now()
loc := now.Location()
y, m, _ := now.Date()
switch period {
case "quarter":
q := (int(m) - 1) / 3
start := time.Date(y, time.Month(q*3+1), 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 3, 0).Add(-time.Nanosecond)
return start.Format("2006-01-02"), end.Format("2006-01-02")
case "year":
start := time.Date(y, 1, 1, 0, 0, 0, 0, loc)
end := start.AddDate(1, 0, 0).Add(-time.Nanosecond)
return start.Format("2006-01-02"), end.Format("2006-01-02")
default: // month
start := time.Date(y, m, 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0).Add(-time.Nanosecond)
return start.Format("2006-01-02"), end.Format("2006-01-02")
}
}
// Detail GET /backend/crm/contract/:id
func (c *BackendCrmContractController) Detail() {
claims, err := pipelineClaims(&c.Controller)
@@ -179,6 +226,7 @@ func (c *BackendCrmContractController) Detail() {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
c.syncContractExceptionStatus(&row)
pipelineOk(&c.Controller, buildContractResp(&row))
}
@@ -214,6 +262,7 @@ func (c *BackendCrmContractController) Create() {
SignDate: parsePipelineDate(p.SignDate),
EffectiveDate: parsePipelineDate(p.EffectiveDate),
ExpireDate: parsePipelineDate(p.ExpireDate),
TechDate: parsePipelineDate(p.TechDate),
Status: pickInt8(p.Status, 1, 5),
Step: pickInt8(p.Step, 1, 2),
Remark: p.Remark,
@@ -332,6 +381,7 @@ func (c *BackendCrmContractController) Update() {
row.SignDate = parsePipelineDate(p.SignDate)
row.EffectiveDate = parsePipelineDate(p.EffectiveDate)
row.ExpireDate = parsePipelineDate(p.ExpireDate)
row.TechDate = parsePipelineDate(p.TechDate)
if p.Parties != nil {
parties, err := normalizeContractJSON(p.Parties)
if err != nil {
@@ -532,6 +582,23 @@ func normalizeContractJSON(raw json.RawMessage) (string, error) {
// 合同总金额(含税)=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本通常不含税,原值汇总);
// 合同总利润=总金额-总成本。
// 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他。
// contractTreeSellTotal 软件开发模块树售价合计:子节点汇总,叶子=人天×统一人天单价。
func contractTreeSellTotal(nodes []interface{}, unitPrice float64) float64 {
var sum float64
for _, n := range nodes {
m, ok := n.(map[string]interface{})
if !ok {
continue
}
if kids, ok := m["children"].([]interface{}); ok && len(kids) > 0 {
sum += contractTreeSellTotal(kids, unitPrice)
continue
}
sum += round2(toFloat64(m["man_days"]) * unitPrice)
}
return round2(sum)
}
func applyContractAmounts(row *models.TenantCrmContract) {
var items []map[string]interface{}
if strings.TrimSpace(row.Products) != "" {
@@ -539,6 +606,14 @@ func applyContractAmounts(row *models.TenantCrmContract) {
}
var hardware, software, other, cost float64
for _, item := range items {
// 软件开发行:金额来自模块树汇总(统一人天单价×人天,不计税率/成本,与前端 buildSummary 一致)
if fmt.Sprintf("%v", item["line_type"]) == "dev" {
if tree, ok := item["dev_tree"].([]interface{}); ok {
unitPrice := toFloat64(item["dev_unit_price"])
software = round2(software + contractTreeSellTotal(tree, unitPrice))
}
continue
}
qty := toFloat64(item["quantity"])
price := toFloat64(item["price"])
costPrice := toFloat64(item["cost_price"])
@@ -563,6 +638,42 @@ func applyContractAmounts(row *models.TenantCrmContract) {
row.TotalProfit = round2(row.TotalAmount - cost)
}
// contractReceivedAmount 汇总合同已回款金额(该合同下所有未删除回款计划的 received_amount 之和)。
func contractReceivedAmount(tenantID string, contractID uint64) float64 {
if contractID == 0 {
return 0
}
var rows []orm.Params
sql := "SELECT IFNULL(SUM(received_amount),0) AS received FROM " +
new(models.TenantCrmPayback).TableName() +
" WHERE tenant_id = ? AND contract_id = ? AND delete_time IS NULL"
if _, err := models.Orm.Raw(sql, tenantID, contractID).Values(&rows); err != nil || len(rows) == 0 {
return 0
}
return toFloat64(rows[0]["received"])
}
// syncContractExceptionStatus 履约中合同状态对账:技术日期已过但回款未回完(已收 < 合同总额,
// 允许 0.005 浮点容差)时,自动流转为「执行异常」(5)。无技术日期或不满足翻转条件则不动。
// 在列表 / 详情读取时触发,也可在回款登记后调用,使状态及时反映。
func (c *BackendCrmContractController) syncContractExceptionStatus(row *models.TenantCrmContract) {
if row == nil || row.Status != 4 || row.TechDate == nil || row.ID == 0 {
return
}
now := time.Now()
if !row.TechDate.Before(now) {
return
}
received := contractReceivedAmount(row.TenantID, row.ID)
if row.TotalAmount > 0 && received+0.005 < row.TotalAmount {
row.Status = 5
row.UpdateTime = now
_, _ = models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", row.ID).Filter("tenant_id", row.TenantID).
Update(orm.Params{"status": int8(5), "update_time": now})
}
}
// genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。
func genContractNo(tenantID string) string {
for i := 0; i < 5; i++ {
+387
View File
@@ -0,0 +1,387 @@
package controllers
import (
"fmt"
"strings"
"time"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// BackendCrmDashboardController CRM 数据仪表盘:按时间维度聚合真实业务数据。
type BackendCrmDashboardController struct {
beego.Controller
}
/* ------------------------------ 时间维度 ------------------------------ */
// crmPeriod 返回当前周期与上一个周期的起止时间(含当天)。
func crmPeriod(r string) (curStart, curEnd, prevStart, prevEnd time.Time) {
now := time.Now()
y, m, d := now.Date()
loc := now.Location()
switch r {
case "day":
curStart = time.Date(y, m, d, 0, 0, 0, 0, loc)
curEnd = curStart.Add(24*time.Hour - time.Second)
prevStart = curStart.AddDate(0, 0, -1)
prevEnd = curEnd.AddDate(0, 0, -1)
case "week":
wd := int(now.Weekday()) // 0 周日 ~ 6 周六
if wd == 0 {
wd = 7
}
monday := time.Date(y, m, d, 0, 0, 0, 0, loc).AddDate(0, 0, -(wd - 1))
curStart = monday
curEnd = monday.AddDate(0, 0, 7).Add(-time.Second)
prevStart = monday.AddDate(0, 0, -7)
prevEnd = curStart.Add(-time.Second)
case "month":
curStart = time.Date(y, m, 1, 0, 0, 0, 0, loc)
curEnd = time.Date(y, m+1, 1, 0, 0, 0, 0, loc).Add(-time.Second)
prevStart = time.Date(y, m-1, 1, 0, 0, 0, 0, loc)
prevEnd = curStart.Add(-time.Second)
case "quarter":
q := (int(m) - 1) / 3
qs := time.Date(y, time.Month(q*3+1), 1, 0, 0, 0, 0, loc)
curStart = qs
curEnd = qs.AddDate(0, 3, 0).Add(-time.Second)
prevStart = qs.AddDate(0, -3, 0)
prevEnd = curStart.Add(-time.Second)
default: // year
curStart = time.Date(y, 1, 1, 0, 0, 0, 0, loc)
curEnd = time.Date(y+1, 1, 1, 0, 0, 0, 0, loc).Add(-time.Second)
prevStart = time.Date(y-1, 1, 1, 0, 0, 0, 0, loc)
prevEnd = curStart.Add(-time.Second)
}
return
}
// crmBucketExpr 按时间维度返回分桶表达式(0..n-1 的整数桶序号)。
func crmBucketExpr(r, dateCol string) string {
switch r {
case "day":
return fmt.Sprintf("FLOOR(HOUR(%s)/2)", dateCol) // 每 2 小时一桶,0~11
case "week":
return fmt.Sprintf("WEEKDAY(%s)", dateCol) // 周一=0 ~ 周日=6
case "month":
return fmt.Sprintf("DAY(%s)-1", dateCol) // 1日=0
case "quarter":
return fmt.Sprintf("(MONTH(%s)-1) %% 3", dateCol) // 季度内月序号 0~2
default:
return fmt.Sprintf("MONTH(%s)-1", dateCol) // 年内月序号 0~11
}
}
// crmAxis 返回与分桶对齐的 X 轴文案。
func crmAxis(r string, start time.Time) []string {
loc := start.Location()
switch r {
case "day":
ax := make([]string, 12)
for i := 0; i < 12; i++ {
ax[i] = fmt.Sprintf("%02d:00", i*2)
}
return ax
case "week":
return []string{"周一", "周二", "周三", "周四", "周五", "周六", "周日"}
case "month":
last := time.Date(start.Year(), start.Month()+1, 0, 0, 0, 0, 0, loc).Day()
ax := make([]string, last)
for i := 0; i < last; i++ {
ax[i] = fmt.Sprintf("%d日", i+1)
}
return ax
case "quarter":
q := (int(start.Month()) - 1) / 3
ax := make([]string, 3)
for i := 0; i < 3; i++ {
ax[i] = fmt.Sprintf("%d月", q*3+i+1)
}
return ax
default:
ax := make([]string, 12)
for i := 0; i < 12; i++ {
ax[i] = fmt.Sprintf("%d月", i+1)
}
return ax
}
}
/* ------------------------------ 聚合查询 ------------------------------ */
func crmCountWhere(tenantID, table, dateCol, extra string, start, end time.Time) float64 {
var n int64
sql := fmt.Sprintf(
"SELECT COUNT(*) FROM %s WHERE tenant_id=? AND delete_time IS NULL AND %s BETWEEN ? AND ? %s",
table, dateCol, extra,
)
if err := models.Orm.Raw(sql, tenantID, start, end).QueryRow(&n); err != nil {
return 0
}
return float64(n)
}
func crmSumWhere(tenantID, table, dateCol, sumCol, extra string, start, end time.Time) float64 {
var s float64
sql := fmt.Sprintf(
"SELECT COALESCE(SUM(%s),0) FROM %s WHERE tenant_id=? AND delete_time IS NULL AND %s BETWEEN ? AND ? %s",
sumCol, table, dateCol, extra,
)
if err := models.Orm.Raw(sql, tenantID, start, end).QueryRow(&s); err != nil {
return 0
}
return s
}
// crmSeriesWhere 按时间分桶返回序列(长度 = bucketCount,缺失桶补 0)。
func crmSeriesWhere(tenantID, table, dateCol, metric, sumCol, extra, bucketExpr string, bucketCount int, start, end time.Time) []float64 {
out := make([]float64, bucketCount)
var rows []struct {
B int `orm:"column(b)"`
V float64 `orm:"column(v)"`
}
var sel string
if metric == "sum" {
sel = fmt.Sprintf("SELECT %s AS b, COALESCE(SUM(%s),0) AS v", bucketExpr, sumCol)
} else {
sel = fmt.Sprintf("SELECT %s AS b, COUNT(*) AS v", bucketExpr)
}
sql := fmt.Sprintf(
"%s FROM %s WHERE tenant_id=? AND delete_time IS NULL AND %s BETWEEN ? AND ? %s GROUP BY b",
sel, table, dateCol, extra,
)
if _, err := models.Orm.Raw(sql, tenantID, start, end).QueryRows(&rows); err != nil {
return out
}
for _, row := range rows {
if row.B >= 0 && row.B < bucketCount {
out[row.B] = row.V
}
}
return out
}
func crmRound1(v float64) float64 {
return float64(int64(v*10+0.5)) / 10
}
/* ------------------------------ 实体定义 ------------------------------ */
type dashEntity struct {
table string
dateCol string
extra string
}
// crmCountEntities 计数的实体(新增客户/联系人/项目/合同/线索/商机/回访)。
func crmCountEntities() map[string]dashEntity {
return map[string]dashEntity{
"newCustomer": {"yz_backend_erp_customer", "create_time", " AND in_pool=0 AND is_draft=0"},
"newContact": {"yz_backend_contact_company", "create_time", " AND company_type='customer'"},
"newProject": {"yz_backend_crm_project", "create_time", ""},
"newContract": {"yz_backend_crm_contract", "create_time", ""},
"newClue": {"yz_backend_crm_clue", "create_time", ""},
"newChance": {"yz_backend_crm_business", "create_time", ""},
"newVisit": {"yz_backend_crm_follow", "follow_time", " AND follow_time IS NOT NULL"},
}
}
// crmSumEntities 求和的实体(各金额)。
func crmSumEntities() map[string]dashEntity {
return map[string]dashEntity{
"projectAmount": {"yz_backend_crm_project", "create_time", ""},
"contractAmount": {"yz_backend_crm_contract", "create_time", ""},
"chanceAmount": {"yz_backend_crm_business", "create_time", ""},
"paymentAmount": {"yz_backend_crm_payback", "create_time", ""},
}
}
func crmAmountCol(key string) string {
switch key {
case "projectAmount":
return "amount"
case "contractAmount":
return "total_amount"
case "chanceAmount":
return "amount"
case "paymentAmount":
return "received_amount"
}
return "amount"
}
/* ------------------------------ 主接口 ------------------------------ */
// Summary GET /backend/crm/dashboard?range=day|week|month|quarter|year
// 返回仪表盘所需的全部真实统计数据。
func (c *BackendCrmDashboardController) Summary() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
tenantID := pipelineTenantID(claims)
r := strings.TrimSpace(c.GetString("range"))
valid := map[string]bool{"day": true, "week": true, "month": true, "quarter": true, "year": true}
if !valid[r] {
r = "month"
}
curStart, curEnd, prevStart, prevEnd := crmPeriod(r)
axis := crmAxis(r, curStart)
bucketCount := len(axis)
bucketExpr := crmBucketExpr(r, "create_time")
counts := crmCountEntities()
sums := crmSumEntities()
statKeys := []string{
"newCustomer", "newContact", "newProject", "newContract", "newClue",
"newChance", "projectAmount", "contractAmount", "chanceAmount", "paymentAmount", "newVisit",
}
stats := map[string]map[string]float64{}
for _, k := range statKeys {
var cur, prev float64
if e, ok := counts[k]; ok {
cur = crmCountWhere(tenantID, e.table, e.dateCol, e.extra, curStart, curEnd)
prev = crmCountWhere(tenantID, e.table, e.dateCol, e.extra, prevStart, prevEnd)
} else if e, ok := sums[k]; ok {
col := crmAmountCol(k)
cur = crmSumWhere(tenantID, e.table, e.dateCol, col, e.extra, curStart, curEnd)
prev = crmSumWhere(tenantID, e.table, e.dateCol, col, e.extra, prevStart, prevEnd)
}
var trend float64
if prev > 0 {
trend = (cur - prev) / prev * 100
} else if cur > 0 {
trend = 100
}
stats[k] = map[string]float64{"value": crmRound1(cur), "trend": crmRound1(trend)}
}
// 趋势图:新增客户 / 新增商机 按桶分布
customerSeries := crmSeriesWhere(tenantID, counts["newCustomer"].table, counts["newCustomer"].dateCol,
"count", "", counts["newCustomer"].extra, crmBucketExpr(r, counts["newCustomer"].dateCol), bucketCount, curStart, curEnd)
chanceSeries := crmSeriesWhere(tenantID, counts["newChance"].table, counts["newChance"].dateCol,
"count", "", counts["newChance"].extra, crmBucketExpr(r, counts["newChance"].dateCol), bucketCount, curStart, curEnd)
// 金额对比图:合同金额 / 回款金额 按桶分布
contractSeries := crmSeriesWhere(tenantID, sums["contractAmount"].table, sums["contractAmount"].dateCol,
"sum", "total_amount", sums["contractAmount"].extra, bucketExpr, bucketCount, curStart, curEnd)
paymentSeries := crmSeriesWhere(tenantID, sums["paymentAmount"].table, sums["paymentAmount"].dateCol,
"sum", "received_amount", sums["paymentAmount"].extra, crmBucketExpr(r, sums["paymentAmount"].dateCol), bucketCount, curStart, curEnd)
// 销售漏斗:线索 / 商机 / 合同 / 回款(期内各实体新增数)
funnel := []map[string]interface{}{
{"name": "线索", "value": crmCountWhere(tenantID, counts["newClue"].table, counts["newClue"].dateCol, counts["newClue"].extra, curStart, curEnd)},
{"name": "商机", "value": crmCountWhere(tenantID, counts["newChance"].table, counts["newChance"].dateCol, counts["newChance"].extra, curStart, curEnd)},
{"name": "合同", "value": crmCountWhere(tenantID, counts["newContract"].table, counts["newContract"].dateCol, counts["newContract"].extra, curStart, curEnd)},
{"name": "回款", "value": crmCountWhere(tenantID, sums["paymentAmount"].table, sums["paymentAmount"].dateCol, sums["paymentAmount"].extra, curStart, curEnd)},
}
// 线索来源分布
var srcRows []struct {
Source string `orm:"column(source)"`
Cnt float64 `orm:"column(cnt)"`
}
srcSQL := "SELECT source, COUNT(*) AS cnt FROM yz_backend_crm_clue WHERE tenant_id=? AND delete_time IS NULL AND create_time BETWEEN ? AND ? GROUP BY source ORDER BY cnt DESC"
_, _ = models.Orm.Raw(srcSQL, tenantID, curStart, curEnd).QueryRows(&srcRows)
source := make([]map[string]interface{}, 0, len(srcRows))
for _, row := range srcRows {
name := strings.TrimSpace(row.Source)
if name == "" {
name = "未填写"
}
source = append(source, map[string]interface{}{"name": name, "value": row.Cnt})
}
// 销售业绩排行:按负责人合同金额 Top8,并补齐回款金额
var rankRows []struct {
OwnerUserID string `orm:"column(owner_user_id)"`
OwnerUserName string `orm:"column(owner_user_name)"`
Contract float64 `orm:"column(contract)"`
}
rankSQL := "SELECT owner_user_id, owner_user_name, COALESCE(SUM(total_amount),0) AS contract FROM yz_backend_crm_contract WHERE tenant_id=? AND delete_time IS NULL AND create_time BETWEEN ? AND ? GROUP BY owner_user_id, owner_user_name ORDER BY contract DESC LIMIT 8"
_, _ = models.Orm.Raw(rankSQL, tenantID, curStart, curEnd).QueryRows(&rankRows)
paymentMap := map[string]float64{}
var payRows []struct {
OwnerUserID string `orm:"column(owner_user_id)"`
Payment float64 `orm:"column(payment)"`
}
paySQL := "SELECT owner_user_id, COALESCE(SUM(received_amount),0) AS payment FROM yz_backend_crm_payback WHERE tenant_id=? AND delete_time IS NULL AND create_time BETWEEN ? AND ? GROUP BY owner_user_id"
_, _ = models.Orm.Raw(paySQL, tenantID, curStart, curEnd).QueryRows(&payRows)
for _, row := range payRows {
paymentMap[row.OwnerUserID] = row.Payment
}
rank := make([]map[string]interface{}, 0, len(rankRows))
for _, row := range rankRows {
name := strings.TrimSpace(row.OwnerUserName)
if name == "" {
name = "未知"
}
rank = append(rank, map[string]interface{}{
"name": name,
"dept": "",
"contract": crmRound1(row.Contract),
"payment": crmRound1(paymentMap[row.OwnerUserID]),
})
}
// 最新回访
var visitRows []struct {
RelatedName string `orm:"column(related_name)"`
FollowType string `orm:"column(follow_type)"`
Content string `orm:"column(content)"`
OwnerUserName string `orm:"column(owner_user_name)"`
FollowTime *time.Time `orm:"column(follow_time)"`
}
visitSQL := "SELECT related_name, follow_type, content, owner_user_name, follow_time FROM yz_backend_crm_follow WHERE tenant_id=? AND delete_time IS NULL AND follow_time IS NOT NULL ORDER BY follow_time DESC LIMIT 6"
_, _ = models.Orm.Raw(visitSQL, tenantID).QueryRows(&visitRows)
visits := make([]map[string]interface{}, 0, len(visitRows))
for _, row := range visitRows {
way := strings.TrimSpace(row.FollowType)
if way == "" {
way = "其他"
}
user := strings.TrimSpace(row.OwnerUserName)
if user == "" {
user = "未知"
}
timeStr := ""
if row.FollowTime != nil {
timeStr = row.FollowTime.Format("01-02 15:04")
}
visits = append(visits, map[string]interface{}{
"customer": row.RelatedName,
"way": way,
"content": row.Content,
"user": user,
"time": timeStr,
})
}
pipelineOk(&c.Controller, map[string]interface{}{
"range": r,
"stats": stats,
"trend": map[string]interface{}{
"axis": axis,
"customer": customerSeries,
"chance": chanceSeries,
},
"funnel": funnel,
"amount": map[string]interface{}{
"axis": axis,
"contract": contractSeries,
"payment": paymentSeries,
},
"source": source,
"rank": rank,
"visits": visits,
})
}
+646
View File
@@ -0,0 +1,646 @@
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"
)
// BackendCrmPaybackController CRM 回款管理
//
// 回款计划针对于合同创建:
// - plan_type 回款周期:1月度/2季度/3年度/4进度/5自定义;
// - 月度/季度/年度:明细为「期数 + 支付金额」;
// - 进度:明细为「进度名称 + 进度百分比 + 进度金额」;
// - 自定义:明细为「名称 + 计划日期 + 金额」;
// - 回款进度:按计划明细逐期登记实收金额/日期。
type BackendCrmPaybackController struct {
beego.Controller
}
// paybackItem 计划明细行(items JSON 元素)。
type paybackItem struct {
Seq int `json:"seq"`
Name string `json:"name"`
Percent float64 `json:"percent"`
Amount float64 `json:"amount"`
PlanDate string `json:"plan_date"`
ReceivedAmount float64 `json:"received_amount"`
ReceivedDate string `json:"received_date"`
ReceiptURL string `json:"receipt_url"` // 流水回执地址(选填)
ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填)
Status int8 `json:"status"` // 1未回款/2部分回款/3已回款
}
// paybackPayload 创建 / 更新请求体。
type paybackPayload struct {
ContractID uint64 `json:"contract_id"`
ContractNo string `json:"contract_no"`
ContractName string `json:"contract_name"`
CustomerID uint64 `json:"customer_id"`
CustomerName string `json:"customer_name"`
PlanType int8 `json:"plan_type"`
PayMethod int8 `json:"pay_method"`
RemindDays int `json:"remind_days"`
OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"`
ContractAmount float64 `json:"contract_amount"`
Items json.RawMessage `json:"items"`
Remark string `json:"remark"`
Status int8 `json:"status"`
ReceiptURL string `json:"receipt_url"` // 流水回执地址(选填)
ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填)
}
// paybackResp 列表 / 详情响应:items 解析为 JSON 数组透出。
type paybackResp struct {
models.TenantCrmPayback
Items json.RawMessage `json:"items"`
}
// paybackProgress 回款进度行(跨计划摊平的计划明细)。
type paybackProgress struct {
PaybackID uint64 `json:"payback_id"`
ContractID *uint64 `json:"contract_id"`
ContractNo string `json:"contract_no"`
ContractName string `json:"contract_name"`
CustomerName string `json:"customer_name"`
OwnerUserName string `json:"owner_user_name"`
PlanType int8 `json:"plan_type"`
PayMethod int8 `json:"pay_method"`
Seq int `json:"seq"`
Name string `json:"name"`
Percent float64 `json:"percent"`
Amount float64 `json:"amount"`
PlanDate string `json:"plan_date"`
ReceivedAmount float64 `json:"received_amount"`
ReceivedDate string `json:"received_date"`
Status int8 `json:"status"`
}
// List GET /backend/crm/payback/list
func (c *BackendCrmPaybackController) 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"))
planType := strings.TrimSpace(c.GetString("plan_type"))
payMethod := strings.TrimSpace(c.GetString("pay_method"))
status := strings.TrimSpace(c.GetString("status"))
contractID := strings.TrimSpace(c.GetString("contract_id"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if keyword != "" {
cond = cond.AndCond(orm.NewCondition().
Or("contract_name__contains", keyword).
Or("contract_no__contains", keyword).
Or("customer_name__contains", keyword).
Or("owner_user_name__contains", keyword))
}
if planType != "" {
cond = cond.And("plan_type", planType)
}
if payMethod != "" {
cond = cond.And("pay_method", payMethod)
}
if status != "" {
cond = cond.And("status", status)
}
if contractID != "" {
if cid, err := strconv.ParseUint(contractID, 10, 64); err == nil && cid > 0 {
cond = cond.And("contract_id", cid)
}
}
qs := models.Orm.QueryTable(new(models.TenantCrmPayback)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmPayback
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
items := make([]paybackResp, 0, len(list))
for i := range list {
items = append(items, buildPaybackResp(&list[i]))
}
// 汇总(当前筛选条件下全部计划的计划总额 / 已回款 / 待回款),用于列表页统计卡片
var all []models.TenantCrmPayback
_, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).SetCond(cond).All(&all)
var summaryTotal, summaryReceived float64
for i := range all {
summaryTotal = round2(summaryTotal + all[i].TotalAmount)
summaryReceived = round2(summaryReceived + all[i].ReceivedAmount)
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": items, "total": total, "page": page, "pageSize": pageSize,
"summary": map[string]interface{}{
"total_amount": summaryTotal,
"received_amount": summaryReceived,
"pending_amount": round2(summaryTotal - summaryReceived),
},
})
}
// Detail GET /backend/crm/payback/:id
func (c *BackendCrmPaybackController) 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.TenantCrmPayback
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
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, buildPaybackResp(&row))
}
// Create POST /backend/crm/payback
func (c *BackendCrmPaybackController) 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 paybackPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.ContractID == 0 {
pipelineErr(&c.Controller, 400, 400, "请先选择关联合同")
return
}
tenantID := pipelineTenantID(claims)
itemsJSON, totalAmount, err := normalizePaybackItems(p.Items)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "计划明细格式错误")
return
}
if totalAmount <= 0 {
pipelineErr(&c.Controller, 400, 400, "计划回款总额不能为 0")
return
}
now := time.Now()
row := models.TenantCrmPayback{
TenantID: tenantID,
ContractNo: strings.TrimSpace(p.ContractNo),
ContractName: strings.TrimSpace(p.ContractName),
CustomerName: strings.TrimSpace(p.CustomerName),
PlanType: pickInt8(p.PlanType, 1, 5),
PayMethod: pickInt8(p.PayMethod, 1, 7),
RemindDays: p.RemindDays,
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
ContractAmount: round2(p.ContractAmount),
TotalAmount: totalAmount,
Status: pickInt8(p.Status, 1, 2),
Items: itemsJSON,
Remark: p.Remark,
ReceiptURL: strings.TrimSpace(p.ReceiptURL),
ReceiptName: strings.TrimSpace(p.ReceiptName),
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
}
c.fillFromContract(tenantID, p.ContractID, &row)
applyPaybackReceived(&row)
if _, err := models.Orm.Insert(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, row.ID, "create", "创建回款计划:"+row.ContractName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
// Update PUT /backend/crm/payback/:id
func (c *BackendCrmPaybackController) 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 paybackPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmPayback
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
return
}
if p.ContractID > 0 {
row.ContractID = &p.ContractID
row.ContractNo = strings.TrimSpace(p.ContractNo)
row.ContractName = strings.TrimSpace(p.ContractName)
row.CustomerName = strings.TrimSpace(p.CustomerName)
c.fillFromContract(tenantID, p.ContractID, &row)
}
if p.PlanType != 0 {
row.PlanType = pickInt8(p.PlanType, 1, 5)
}
if p.PayMethod != 0 {
row.PayMethod = pickInt8(p.PayMethod, 1, 7)
}
row.RemindDays = p.RemindDays
if strings.TrimSpace(p.OwnerUserID) != "" {
row.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
}
if strings.TrimSpace(p.OwnerUserName) != "" {
row.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
}
if p.ContractAmount > 0 {
row.ContractAmount = round2(p.ContractAmount)
}
row.Remark = p.Remark
row.ReceiptURL = strings.TrimSpace(p.ReceiptURL)
row.ReceiptName = strings.TrimSpace(p.ReceiptName)
if p.Status != 0 {
row.Status = pickInt8(p.Status, 1, 2)
}
if p.Items != nil {
itemsJSON, _, err := normalizePaybackItems(p.Items)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "计划明细格式错误")
return
}
row.Items = itemsJSON
}
applyPaybackReceived(&row)
row.UpdateTime = time.Now()
if _, err := models.Orm.Update(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, row.ID, "update", "更新回款计划:"+row.ContractName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
// Delete DELETE /backend/crm/payback/:id
func (c *BackendCrmPaybackController) 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.TenantCrmPayback
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
return
}
if !canDeleteCrmRecord(claims, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该回款计划")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmPayback)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
pipelineOk(&c.Controller, nil)
}
// ProgressList GET /backend/crm/payback/progress/list
// 回款进度:把全部回款计划的分期明细摊平返回,并汇总计划总额 / 已回款总额。
func (c *BackendCrmPaybackController) ProgressList() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
tenantID := pipelineTenantID(claims)
keyword := strings.TrimSpace(c.GetString("keyword"))
status := strings.TrimSpace(c.GetString("status"))
contractID := strings.TrimSpace(c.GetString("contract_id"))
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if keyword != "" {
cond = cond.AndCond(orm.NewCondition().
Or("contract_name__contains", keyword).
Or("customer_name__contains", keyword).
Or("owner_user_name__contains", keyword))
}
if contractID != "" {
if cid, err := strconv.ParseUint(contractID, 10, 64); err == nil && cid > 0 {
cond = cond.And("contract_id", cid)
}
}
var list []models.TenantCrmPayback
_, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).SetCond(cond).OrderBy("-id").All(&list)
rows := make([]paybackProgress, 0)
var totalPlanned, totalReceived float64
for i := range list {
p := list[i]
for _, it := range parsePaybackItems(p.Items) {
st := it.Status
if st == 0 {
st = itemStatus(it.ReceivedAmount, it.Amount)
}
if status != "" && fmt.Sprintf("%d", st) != status {
continue
}
totalPlanned = round2(totalPlanned + it.Amount)
totalReceived = round2(totalReceived + it.ReceivedAmount)
rows = append(rows, paybackProgress{
PaybackID: p.ID,
ContractID: p.ContractID,
ContractNo: p.ContractNo,
ContractName: p.ContractName,
CustomerName: p.CustomerName,
OwnerUserName: p.OwnerUserName,
PlanType: p.PlanType,
PayMethod: p.PayMethod,
Seq: it.Seq,
Name: it.Name,
Percent: it.Percent,
Amount: it.Amount,
PlanDate: it.PlanDate,
ReceivedAmount: it.ReceivedAmount,
ReceivedDate: it.ReceivedDate,
Status: st,
})
}
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": rows, "total": len(rows),
"total_planned": totalPlanned, "total_received": totalReceived,
})
}
// Receive POST /backend/crm/payback/:id/receive
// 回款进度登记:按明细序号写入实收金额 / 回款日期,并重算计划汇总。
func (c *BackendCrmPaybackController) Receive() {
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 struct {
Seq int `json:"seq"`
ReceivedAmount float64 `json:"received_amount"`
ReceivedDate string `json:"received_date"`
ReceiptURL string `json:"receipt_url"` // 流水回执地址(选填)
ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填)
}
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmPayback
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
return
}
items := parsePaybackItems(row.Items)
matched := false
for i := range items {
if items[i].Seq != p.Seq {
continue
}
items[i].ReceivedAmount = round2(p.ReceivedAmount)
items[i].ReceivedDate = strings.TrimSpace(p.ReceivedDate)
items[i].ReceiptURL = strings.TrimSpace(p.ReceiptURL)
items[i].ReceiptName = strings.TrimSpace(p.ReceiptName)
items[i].Status = itemStatus(items[i].ReceivedAmount, items[i].Amount)
matched = true
break
}
if !matched {
pipelineErr(&c.Controller, 404, 404, "计划明细不存在")
return
}
out, err := json.Marshal(items)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "保存失败")
return
}
row.Items = string(out)
applyPaybackReceived(&row)
row.UpdateTime = time.Now()
if _, err := models.Orm.Update(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "保存失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, row.ID, "receive", fmt.Sprintf("回款登记:%s 第 %d 期 %s 元", row.ContractName, p.Seq, formatAmount(p.ReceivedAmount)), claims)
pipelineOk(&c.Controller, buildPaybackResp(&row))
}
// ========================== 内部辅助 ==========================
// buildPaybackResp 组装响应:items 透传 JSON 数组。
func buildPaybackResp(row *models.TenantCrmPayback) paybackResp {
items := json.RawMessage("[]")
if strings.TrimSpace(row.Items) != "" {
items = json.RawMessage(row.Items)
}
return paybackResp{TenantCrmPayback: *row, Items: items}
}
// parsePaybackItems 解析明细 JSON(容错:空 / 非法时返回空数组)。
func parsePaybackItems(raw string) []paybackItem {
items := make([]paybackItem, 0)
if strings.TrimSpace(raw) == "" {
return items
}
_ = json.Unmarshal([]byte(raw), &items)
return items
}
// normalizePaybackItems 校验并规范化明细 JSON:补齐序号、金额取两位小数,返回紧凑 JSON 与计划总额。
func normalizePaybackItems(raw json.RawMessage) (string, float64, error) {
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" {
return "[]", 0, nil
}
var arr []map[string]interface{}
if err := json.Unmarshal(raw, &arr); err != nil {
return "", 0, err
}
items := make([]paybackItem, 0, len(arr))
var total float64
for i, m := range arr {
it := paybackItem{
Seq: i + 1,
Name: strings.TrimSpace(strVal(m["name"])),
Percent: round2(toFloat64(m["percent"])),
Amount: round2(toFloat64(m["amount"])),
PlanDate: strings.TrimSpace(strVal(m["plan_date"])),
ReceivedAmount: round2(toFloat64(m["received_amount"])),
ReceivedDate: strings.TrimSpace(strVal(m["received_date"])),
}
if v, ok := m["receipt_url"]; ok && v != nil {
it.ReceiptURL = strings.TrimSpace(fmt.Sprintf("%v", v))
}
if v, ok := m["receipt_name"]; ok && v != nil {
it.ReceiptName = strings.TrimSpace(fmt.Sprintf("%v", v))
}
if v, ok := m["seq"]; ok {
if s := int(toFloat64(v)); s > 0 {
it.Seq = s
}
}
it.Status = itemStatus(it.ReceivedAmount, it.Amount)
total = round2(total + it.Amount)
items = append(items, it)
}
out, err := json.Marshal(items)
if err != nil {
return "", 0, err
}
return string(out), total, nil
}
// itemStatus 由实收金额推导明细状态:0/空=未回款 1、部分回款 2、已回款 3。
func itemStatus(received, amount float64) int8 {
if amount > 0 && received >= amount {
return 3
}
if received > 0 {
return 2
}
return 1
}
// strVal 安全取字符串:nil / 非字符串时返回空串,避免写出 "<nil>"。
func strVal(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
// applyPaybackReceived 按明细重算计划总额、已回款金额与状态。
func applyPaybackReceived(row *models.TenantCrmPayback) {
items := parsePaybackItems(row.Items)
var total, received float64
allDone := len(items) > 0
for i := range items {
if items[i].Status == 0 {
items[i].Status = itemStatus(items[i].ReceivedAmount, items[i].Amount)
}
if items[i].Status != 3 {
allDone = false
}
total = round2(total + items[i].Amount)
received = round2(received + items[i].ReceivedAmount)
}
row.TotalAmount = total
row.ReceivedAmount = received
if out, err := json.Marshal(items); err == nil {
row.Items = string(out)
}
// 状态:1进行中 / 2已完成(全部明细回款完成)
if allDone {
row.Status = 2
} else if row.Status == 0 {
row.Status = 1
}
}
// fillFromContract 由关联合同补齐合同编号 / 名称 / 客户 / 合同金额(仅在字段为空时填充)。
func (c *BackendCrmPaybackController) fillFromContract(tenantID string, contractID uint64, row *models.TenantCrmPayback) {
if contractID == 0 {
return
}
var ct models.TenantCrmContract
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", contractID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&ct); err != nil {
return
}
row.ContractID = &ct.ID
if row.ContractNo == "" {
row.ContractNo = ct.ContractNo
}
if row.ContractName == "" {
row.ContractName = ct.ContractName
}
if row.ContractAmount <= 0 {
row.ContractAmount = ct.TotalAmount
}
if row.CustomerName == "" {
var parties []map[string]interface{}
_ = json.Unmarshal([]byte(ct.Parties), &parties)
for _, party := range parties {
if fmt.Sprintf("%v", party["ref_type"]) != "1" {
continue
}
row.CustomerName = strings.TrimSpace(strVal(party["ref_name"]))
if cid := uint64(toFloat64(party["ref_id"])); cid > 0 {
row.CustomerID = &cid
}
break
}
}
}
// formatAmount 金额文案(用于操作日志)。
func formatAmount(n float64) string {
return strconv.FormatFloat(round2(n), 'f', 2, 64)
}
+21
View File
@@ -28,6 +28,9 @@ type crmProductPayload struct {
Price float64 `json:"price"`
CostPrice float64 `json:"cost_price"`
TaxRate float64 `json:"tax_rate"`
LineType string `json:"line_type"`
DevUnitPrice float64 `json:"dev_unit_price"`
DevTree json.RawMessage `json:"dev_tree"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
@@ -139,6 +142,10 @@ func (c *BackendCrmProductController) Create() {
if status != 0 && status != 1 {
status = 1
}
lineType := strings.TrimSpace(p.LineType)
if lineType != "dev" {
lineType = "product"
}
now := time.Now()
row := models.TenantCrmProduct{
TenantID: pipelineTenantID(claims),
@@ -150,6 +157,9 @@ func (c *BackendCrmProductController) Create() {
Price: p.Price,
CostPrice: p.CostPrice,
TaxRate: p.TaxRate,
LineType: lineType,
DevUnitPrice: p.DevUnitPrice,
DevTree: string(p.DevTree),
Status: status,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
@@ -198,6 +208,10 @@ func (c *BackendCrmProductController) Update() {
if status != 0 && status != 1 {
status = row.Status
}
lineType := strings.TrimSpace(p.LineType)
if lineType != "dev" {
lineType = "product"
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmProduct)).
Filter("id", id).Filter("tenant_id", tenantID).
@@ -210,6 +224,9 @@ func (c *BackendCrmProductController) Update() {
"price": p.Price,
"cost_price": p.CostPrice,
"tax_rate": p.TaxRate,
"line_type": lineType,
"dev_unit_price": p.DevUnitPrice,
"dev_tree": p.DevTree,
"status": status,
"remark": p.Remark,
"update_time": now,
@@ -371,6 +388,10 @@ func SyncContractProducts(tenantID, uid string, raw json.RawMessage) (json.RawMe
}
now := time.Now()
for _, it := range items {
// 软件开发行(line_type=dev)不走产品管理建档,仅保留模块树
if fmt.Sprintf("%v", it["line_type"]) == "dev" {
continue
}
name := strings.TrimSpace(fmt.Sprintf("%v", it["name"]))
if name == "" {
continue
+33 -3
View File
@@ -287,9 +287,20 @@ func (c *BackendCrmAttachController) List() {
includeSource := strings.TrimSpace(c.GetString("include_source")) == "1"
tenantID := pipelineTenantID(claims)
page, _ := strconv.Atoi(c.GetString("page"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
offset := (page - 1) * pageSize
var list []models.TenantCrmAttach
total := 0
if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" {
// 链路模式:线索 → 商机 → 项目 累积
// 链路模式:线索 → 商机 → 项目 累积(跨表汇总后内存分页)
rid, _ := strconv.ParseUint(relatedID, 10, 64)
rt, _ := strconv.Atoi(relatedType)
for _, r := range relatedChain(tenantID, int8(rt), rid) {
@@ -303,6 +314,16 @@ func (c *BackendCrmAttachController) List() {
list = append(list, rows...)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID > list[j].ID })
total = len(list)
start := offset
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
list = list[start:end]
} else {
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if relatedType != "" {
@@ -311,9 +332,18 @@ func (c *BackendCrmAttachController) List() {
if relatedID != "" {
cond = cond.And("related_id", relatedID)
}
_, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond).OrderBy("-id").All(&list)
qs := models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond)
if cnt, err := qs.Count(); err == nil {
total = int(cnt)
}
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
_, _ = qs.OrderBy("-id").Limit(pageSize, offset).All(&list)
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": list,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
// Add POST /backend/crm/attach/add
-18
View File
@@ -1,18 +0,0 @@
-- 创建存储配置表
CREATE TABLE IF NOT EXISTS `yz_system_storage_config` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`storage_type` varchar(20) NOT NULL DEFAULT 'local' COMMENT '存储类型: local-本地存储, qiniu-七牛云',
`qiniu_access_key` varchar(255) DEFAULT NULL COMMENT '七牛云AccessKey',
`qiniu_secret_key` varchar(255) DEFAULT NULL COMMENT '七牛云SecretKey',
`qiniu_bucket` varchar(128) DEFAULT NULL COMMENT '七牛云Bucket名称',
`qiniu_domain` varchar(255) DEFAULT NULL COMMENT '七牛云CDN域名',
`qiniu_region` varchar(50) DEFAULT NULL COMMENT '七牛云存储区域',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统存储配置表';
-- 插入默认配置(本地存储)
INSERT INTO `yz_system_storage_config` (`storage_type`, `create_time`)
VALUES ('local', NOW())
ON DUPLICATE KEY UPDATE `storage_type` = 'local';
@@ -1,3 +0,0 @@
-- 为 AI 聊天消息表增加图片字段(用户上传的图片,base64 dataURL 的 JSON 数组)
ALTER TABLE `yz_backend_ai_chat_message`
ADD COLUMN `images` TEXT NULL COMMENT '用户上传的图片(base64 dataURL 的 JSON 数组)';
@@ -1,6 +0,0 @@
-- 为 AI 聊天消息表增加 token 用量与响应耗时字段
-- 用途:在对话界面展示每条助手回复的响应时间与消耗的 tokens
ALTER TABLE yz_backend_ai_chat_message
ADD COLUMN tokens INT NOT NULL DEFAULT 0 COMMENT '本次响应消耗的 token 总数',
ADD COLUMN duration_ms INT NOT NULL DEFAULT 0 COMMENT 'AI 响应耗时(毫秒)',
ADD COLUMN model VARCHAR(64) NOT NULL DEFAULT '' COMMENT '生成该消息使用的模型';
+63
View File
@@ -0,0 +1,63 @@
-- =============================================================
-- CRM 逾期(overdue)判定与索引
-- 说明:
-- 逾期以「当前日期」实时判定,无需新增存储字段。
-- 前端在列表/详情中展示「逾期 N 天」标签,规则如下:
-- - 项目 : 结束日期(end_date) < 今天 且 状态 != 已完成(3)
-- - 合同 : 到期日期(expire_date) < 今天 且 状态 != 已完成(2) / 已作废(3)
-- - 回款明细: 计划回款日期(plan_date) < 今天 且 明细状态 != 已回款(3)
-- 本脚本仅补充判定所需的日期索引,并给出等价 SQL 供统计/定时任务使用。
-- =============================================================
-- 1) 项目:结束日期索引(逾期判定 + 按结束日期排序)
ALTER TABLE `yz_backend_crm_project`
ADD INDEX `idx_end_date` (`end_date`);
-- 2) 合同:到期日期索引(逾期判定 + 按到期日期排序)
ALTER TABLE `yz_backend_crm_contract`
ADD INDEX `idx_expire_date` (`expire_date`);
-- 3) 回款明细逾期:plan_date 内嵌于 items(JSON),无法建单列索引。
-- 如需按「逾期回款明细」做服务端统计,建议将回款明细拆为独立子表
-- (如 yz_backend_crm_payback_item,含 plan_date DATE 列),再建索引。
-- 当前版本逾期在应用层(前端/接口)实时计算,items 已含 plan_date。
-- =============================================================
-- 等价查询(统计「逾期」记录,供运营/定时任务参考)
-- 注意:DATE(end_date) < CURDATE() 表示「结束日期在今天之前」。
-- =============================================================
-- 逾期项目(结束日期已过且未完成)
SELECT id, project_name, end_date, status
FROM `yz_backend_crm_project`
WHERE `delete_time` IS NULL
AND `end_date` IS NOT NULL
AND DATE(`end_date`) < CURDATE()
AND `status` <> 3; -- 3=已完成
-- 逾期合同(到期日期已过且非已完成/已作废)
SELECT id, contract_name, expire_date, status
FROM `yz_backend_crm_contract`
WHERE `delete_time` IS NULL
AND `expire_date` IS NOT NULL
AND DATE(`expire_date`) < CURDATE()
AND `status` NOT IN (2, 3); -- 2=已完成, 3=已作废
-- 逾期回款明细(MySQL 5.7+ 可用 JSON 函数;低版本建议在应用层计算)
-- 列出「存在任一逾期明细」的回款计划
SELECT p.id, p.contract_name,
j.seq, j.name, j.plan_date, j.status
FROM `yz_backend_crm_payback` p,
JSON_TABLE(
p.items,
'$[*]' COLUMNS (
seq INT PATH '$.seq',
name VARCHAR(100) PATH '$.name',
plan_date DATE PATH '$.plan_date',
status INT PATH '$.status'
)
) j
WHERE p.`delete_time` IS NULL
AND j.plan_date IS NOT NULL
AND DATE(j.plan_date) < CURDATE()
AND j.status <> 3; -- 3=已回款
@@ -0,0 +1,42 @@
-- =============================================================
-- 回款计划表(yz_backend_crm_payback)建表 / 核对脚本
-- =============================================================
-- 说明:
-- 1. 计划明细 items 以 JSON 文本存储,plan_date(计划回款日期)已内置于
-- 各类 plan_type 的明细结构中,无需新增独立列,故无 ALTER TABLE 语句。
-- 2. 各 plan_type 的 items 明细结构:
-- 月度/季度/年度:{"seq":int,"name":"第1期","amount":0,"plan_date":"2026-10-01"}
-- 进度: {"seq":int,"name":"预付款","percent":30,"amount":0,"plan_date":"2026-10-01"}
-- 自定义: {"seq":int,"name":"首付款","plan_date":"2026-10-01","amount":0}
-- 每行另含 received_amount / received_date / status(回款进度登记用)。
-- 3. 执行下方建表脚本可创建或核对表结构(幂等,已存在则跳过)。
-- =============================================================
CREATE TABLE IF NOT EXISTS `yz_backend_crm_payback` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` VARCHAR(64) NOT NULL DEFAULT '',
`contract_id` BIGINT UNSIGNED NULL,
`contract_no` VARCHAR(50) NOT NULL DEFAULT '',
`contract_name` VARCHAR(100) NOT NULL DEFAULT '',
`customer_id` BIGINT UNSIGNED NULL,
`customer_name` VARCHAR(128) NOT NULL DEFAULT '',
`plan_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1月度/2季度/3年度/4进度/5自定义',
`pay_method` TINYINT NOT NULL DEFAULT 1 COMMENT '1对公/2网银/3现金/4支票/5支付宝/6微信/7其他',
`remind_days` INT NOT NULL DEFAULT 0 COMMENT '提前几天提醒',
`owner_user_id` VARCHAR(64) NOT NULL DEFAULT '',
`owner_user_name` VARCHAR(128) NOT NULL DEFAULT '',
`contract_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '合同总金额',
`total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '计划回款总额',
`received_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '已回款金额',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1进行中/2已完成',
`items` TEXT NULL COMMENT '计划明细 JSON 数组(含 plan_date 计划回款日期)',
`remark` TEXT NULL,
`create_user_id` VARCHAR(64) NOT NULL DEFAULT '',
`create_time` DATETIME NOT NULL,
`update_time` DATETIME NOT NULL,
`delete_time` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_yz_backend_crm_payback_tenant` (`tenant_id`),
KEY `idx_yz_backend_crm_payback_contract` (`contract_id`),
KEY `idx_yz_backend_crm_payback_delete` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回款计划表';
@@ -0,0 +1,12 @@
-- =============================================================
-- CRM 回款计划:流水回执(receipt)字段
-- 说明:
-- 回款登记支持上传「流水回执」(银行流水 / 转账凭证等),非必填。
-- 新增两列用于保存回执文件地址与文件名,随回款计划主表一并存取。
-- 文件本身经 /backend/uploadfile 上传到文件存储,此处仅存其可访问地址。
-- =============================================================
-- 回款计划表:流水回执地址 + 文件名(均允许为空)
ALTER TABLE `yz_backend_crm_payback`
ADD COLUMN `receipt_url` VARCHAR(512) NULL COMMENT '流水回执文件地址(选填)' AFTER `remark`,
ADD COLUMN `receipt_name` VARCHAR(255) NULL COMMENT '流水回执文件名(选填)' AFTER `receipt_url`;
@@ -1,15 +0,0 @@
-- 项目表新增「产品清单」字段: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;
@@ -1,93 +0,0 @@
-- 文件存储分层改造:yz_system_files 新增归属与存储字段
-- 对应计划文档:go/docs/文件存储分层改造计划.md S1
--
-- 幂等脚本:用存储过程逐项判断,已存在的列/索引自动跳过,可反复执行,不会报
-- 1060 Duplicate column / 1061 Duplicate key。
--
-- 背景:md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引,
-- 因此 idx_file_dedup 使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符,等价全值)。
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
DELIMITER $$
CREATE PROCEDURE `yz_alter_system_files_storage`()
BEGIN
DECLARE v_db VARCHAR(64);
SET v_db = DATABASE();
-- 1. 字段:source
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'source'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `source` varchar(16) NOT NULL DEFAULT 'backend'
COMMENT '来源端: backend-租户后台 platform-平台端';
END IF;
-- 2. 字段:scope
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'scope'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT 'tenant'
COMMENT '归属: tenant-租户共享 user-用户个人';
END IF;
-- 3. 字段:storage
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'storage'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT ''
COMMENT '存储类型: local/qiniu(冗余,便于迁移与排查)';
END IF;
-- 4. 字段:object_key
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'object_key'
) THEN
ALTER TABLE `yz_system_files`
ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT ''
COMMENT '存储相对路径(不含域名),用于迁移与精确删除';
END IF;
-- 5. 索引:查重(source + scope + tid + tuid + md5 前缀)
-- 不加 UNIQUE:软删(delete_time)与并发上传下唯一索引会直接报错,去重在代码层完成
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_dedup'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`(32));
END IF;
-- 6. 索引:归属列表过滤
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = v_db AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_owner'
) THEN
ALTER TABLE `yz_system_files`
ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`);
END IF;
END$$
DELIMITER ;
CALL `yz_alter_system_files_storage`();
DROP PROCEDURE IF EXISTS `yz_alter_system_files_storage`;
-- 7. 老数据初始化:迁移脚本执行前先打底,避免 source/scope 为空导致查询遗漏
UPDATE `yz_system_files` SET `source` = 'backend' WHERE `source` = '' OR `source` IS NULL;
UPDATE `yz_system_files` SET `scope` = 'tenant' WHERE `scope` = '' OR `scope` IS NULL;
-- 8. 校验(应看到 4 个字段 + 2 个索引)
-- SHOW COLUMNS FROM yz_system_files LIKE 'source';
-- SHOW COLUMNS FROM yz_system_files LIKE 'scope';
-- SHOW COLUMNS FROM yz_system_files LIKE 'storage';
-- SHOW COLUMNS FROM yz_system_files LIKE 'object_key';
-- SHOW INDEX FROM yz_system_files WHERE Key_name IN ('idx_file_dedup','idx_file_owner');
-29
View File
@@ -1,29 +0,0 @@
-- 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产品管理表';
@@ -1,19 +0,0 @@
-- 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产品分类表';
-45
View File
@@ -1,45 +0,0 @@
-- 投诉建议「产品分类」:区分用户针对哪类产品提建议
-- 请在目标库手动执行(utf8mb4)
CREATE TABLE IF NOT EXISTS `yz_system_complaint_category` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL COMMENT '分类名称,如:官网、租户后台、小程序',
`code` varchar(32) DEFAULT NULL COMMENT '可选编码,便于程序识别',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0禁用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` datetime DEFAULT NULL COMMENT '软删',
PRIMARY KEY (`id`),
KEY `idx_delete_time` (`delete_time`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='投诉建议-产品分类';
CREATE TABLE IF NOT EXISTS `yz_system_platform_complaint` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`category_id` bigint unsigned NOT NULL COMMENT '产品分类ID',
`title` varchar(200) NOT NULL COMMENT '标题',
`content` text NOT NULL COMMENT '建议/投诉内容',
`contact_name` varchar(64) DEFAULT NULL COMMENT '联系人',
`contact_phone` varchar(32) DEFAULT NULL COMMENT '联系电话',
`contact_email` varchar(128) DEFAULT NULL COMMENT '联系邮箱',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1处理中 2已回复 3已关闭',
`reply_content` text COMMENT '平台回复内容',
`reply_time` datetime DEFAULT NULL COMMENT '回复时间',
`tid` bigint unsigned DEFAULT NULL COMMENT '可选:关联租户ID',
`remark` varchar(512) DEFAULT NULL COMMENT '管理员内部备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` datetime DEFAULT NULL COMMENT '软删',
PRIMARY KEY (`id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_status` (`status`),
KEY `idx_delete_time` (`delete_time`),
KEY `idx_tid` (`tid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台端-投诉建议';
-- 可选:示例分类(执行完建表后按需取消注释)
-- INSERT INTO `yz_system_complaint_category` (`name`,`code`,`sort`,`status`) VALUES
-- ('官网','site',0,1),
-- ('租户后台','tenant_admin',10,1),
-- ('小程序','miniapp',20,1);
-129
View File
@@ -1,129 +0,0 @@
-- OA 文档管理(文档库 / 文档图谱)建表脚本
--
-- 说明:正常情况【不需要执行本脚本】。
-- 服务在首次访问 /backend/oa/document/* 接口时,会由
-- controllers/backend_oa_document.go 的 Prepare() 调用
-- models.EnsureOaDocumentTables() 自动建表(CREATE TABLE IF NOT EXISTS,
-- sync.Once 保证单进程只建一次)。
--
-- 本脚本仅用于以下场景手工预建:
-- 1. 生产库账号没有 DDL 权限,自动建表失败;
-- 2. DBA 要求先在预发/生产环境审核建表语句;
-- 3. 需要排查表结构是否符合预期。
--
-- 幂等:全部使用 IF NOT EXISTS,可反复执行,不会覆盖已有数据。
-- 与代码保持一致:models/oa_document.go
-- 1. 文档分类表(树形)
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_category (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '分类归属用户:共享空间共享=0(租户级);私密空间=创建者的 yz_system_tenant_user.uid',
parent_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '父级ID,0为一级分类',
name varchar(128) NOT NULL DEFAULT '' COMMENT '分类名称',
sort int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)',
scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属空间 shared-共享文档空间 personal-私密文档空间(绑定用户级别)',
is_deleted tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (id),
KEY idx_tid_parent (tid, parent_id),
KEY idx_tid_scope_user (tid, scope, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档分类表';
-- 1.1 历史库补齐列(特性上线前已建表时执行,已存在则报错可忽略)
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)';
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属空间 shared-共享 personal-私密';
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '分类归属用户(私密空间=租户用户 uid)';
-- 1.2 分类归属规则(双空间模型,2026-09 起):
-- 文档库按「共享文档 / 私密文档」两个空间组织,每个空间各自一棵分类树:
-- - 共享空间:scope=shared、user_id=0(租户级,全租户共用一套,任何人可建/删,内置分类除外);
-- - 私密空间:scope=personal、user_id=当前用户的 yz_system_tenant_user.uid(绑定到用户级别,各用户独立一套)。
-- 文档可见性(OaDoc.visibility)与所在空间一一对应:共享空间→0(租户公开),私密空间→1(私密,仅创建者与被共享者可见)。
-- 系统内置分类「项目文档」(scope=shared, user_id=0, is_system=1) 属于共享空间,按租户自动补建
-- (models.EnsureOaDocDefaultCategories),一般无需手工插入。
-- 历史脏数据(scope 不在 shared/personal)在 EnsureOaDocumentTables 中自动归为共享空间(user_id=0, scope=shared)。
-- 说明:「未分类」是虚拟分类,文档 category_id = 0 即表示未分类,两个空间各自独立计算,不落库、不可删除。
-- 2. 文档表
-- doc_type: 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频
-- status : 0-草稿 1-已发布 2-已归档
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
category_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '所属分类ID,0为未分类',
title varchar(255) NOT NULL DEFAULT '' COMMENT '文档标题',
file_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '关联附件ID(yz_system_files)',
file_url varchar(512) NOT NULL DEFAULT '' COMMENT '附件访问地址',
file_name varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名',
ext varchar(16) NOT NULL DEFAULT '' COMMENT '扩展名',
size bigint unsigned NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
doc_type tinyint NOT NULL DEFAULT 0 COMMENT '类型 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频',
tags varchar(255) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔',
summary varchar(1000) NOT NULL DEFAULT '' COMMENT '摘要/描述',
status tinyint NOT NULL DEFAULT 0 COMMENT '状态 0-草稿 1-已发布 2-已归档',
version int NOT NULL DEFAULT 1 COMMENT '版本号',
is_star tinyint NOT NULL DEFAULT 0 COMMENT '是否收藏 0-否 1-是',
owner_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '负责人ID',
owner_name varchar(100) NOT NULL DEFAULT '' COMMENT '负责人姓名',
view_count int NOT NULL DEFAULT 0 COMMENT '查看次数',
download_count int NOT NULL DEFAULT 0 COMMENT '下载次数',
creator_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID',
creator_name varchar(100) NOT NULL DEFAULT '' COMMENT '创建人姓名',
visibility tinyint NOT NULL DEFAULT 0 COMMENT '可见性 0-租户公开 1-私密(仅创建者与被共享者)',
is_deleted tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (id),
KEY idx_tid_cate (tid, category_id, is_deleted),
KEY idx_tid_status (tid, status, is_deleted),
KEY idx_tid_title (tid, title)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档表';
-- 3. 文档关联表(文档图谱的连线来源)
-- relation: reference-引用 related-相关 version-版本 belong-从属
-- 说明:代码在建立关联时会同时写入正向与反向两条记录,便于图谱无向检索;
-- uk_doc_pair 保证同一对文档的同一关系不重复。
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_link (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
source_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '源文档ID',
target_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '目标文档ID',
relation varchar(32) NOT NULL DEFAULT 'related' COMMENT '关系 reference-引用 related-相关 version-版本 belong-从属',
remark varchar(255) NOT NULL DEFAULT '' COMMENT '关系说明',
creator_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_doc_pair (tid, source_id, target_id, relation),
KEY idx_tid_source (tid, source_id),
KEY idx_tid_target (tid, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档关联表';
-- 4. 文档共享表(私密文档可见性授权)
-- share_type: 0-用户(target_id=uid) 1-部门(target_id=org_id,含子部门)
-- uk_doc_target 保证同一文档对同一目标不重复授权。
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_share (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
doc_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '文档ID',
share_type tinyint NOT NULL DEFAULT 0 COMMENT '共享类型 0-用户(target_id=uid) 1-部门(target_id=org_id,含子部门)',
target_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '共享目标ID',
creator_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '授权人ID',
create_time datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_doc_target (tid, doc_id, share_type, target_id),
KEY idx_tid_doc (tid, doc_id),
KEY idx_target (tid, share_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档共享表';
-- 5. 校验(应看到 4 张表)
-- SHOW TABLES LIKE 'yz_backend_oa_doc%';
@@ -1,31 +0,0 @@
-- Cursor 激活码管理
-- status: 0 未使用 1 已使用 2 已过期 3 已禁用
-- type: 0 自定义 1 天卡 7 周卡 30 月卡 90 季卡 365 年卡
CREATE TABLE IF NOT EXISTS `yz_platform_cursor_activation_code` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`code` varchar(128) NOT NULL COMMENT '激活码',
`type` int NOT NULL DEFAULT 30 COMMENT '卡密类型:0自定义 1天卡 7周卡 30月卡 90季卡 365年卡',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0未使用 1已使用 2已过期 3已禁用',
`duration_days` int NOT NULL DEFAULT 30 COMMENT '有效天数',
`bind_account` varchar(128) DEFAULT NULL COMMENT '绑定账号',
`bind_device_id` bigint unsigned DEFAULT NULL COMMENT '绑定设备ID,关联 yz_platform_cursor_equipment.id',
`machine_code` varchar(128) DEFAULT NULL COMMENT '绑定设备机器码',
`device_info` varchar(1000) DEFAULT NULL COMMENT '绑定设备信息',
`owner_user_id` bigint unsigned DEFAULT NULL COMMENT '归属用户ID',
`owner_user_name` varchar(128) DEFAULT NULL COMMENT '归属用户名称',
`activated_at` datetime DEFAULT NULL COMMENT '激活时间',
`expired_at` datetime DEFAULT NULL COMMENT '过期时间',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_status_delete` (`status`,`delete_time`),
KEY `idx_type_status` (`type`,`status`),
KEY `idx_bind_account` (`bind_account`),
KEY `idx_bind_device_id` (`bind_device_id`),
KEY `idx_owner_user_id` (`owner_user_id`),
KEY `idx_expired_at` (`expired_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Cursor续杯激活码';
-26
View File
@@ -1,26 +0,0 @@
-- 软件升级产品(客户端拉取版本与下载地址)
-- 安装包建议上传到文件管理,分类使用「appsupgrade」(或任意分类,记录 file_id 即可)
CREATE TABLE IF NOT EXISTS `yz_system_software_upgrade` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(128) NOT NULL COMMENT '软件显示名称',
`code` varchar(64) NOT NULL COMMENT '客户端唯一标识,与 check 接口 code 一致',
`latest_version` varchar(32) NOT NULL DEFAULT '0.0.0' COMMENT '当前发布的最新版本号',
`file_id` bigint unsigned DEFAULT NULL COMMENT '关联 yz_system_files.id,安装包',
`download_url` varchar(512) DEFAULT NULL COMMENT '兼容旧客户端的单安装包地址;为空则用 file_id 对应 src 拼公开 URL',
`download_urls` text DEFAULT NULL COMMENT '多运行环境安装包地址 JSON,如 {"windows":"...","mac":"...","ubuntu":"...","linux":"..."}',
`force_update` tinyint NOT NULL DEFAULT 0 COMMENT '1 建议强制更新',
`release_notes` varchar(2000) DEFAULT NULL COMMENT '更新说明',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1 启用 0 停用',
`sort` int NOT NULL DEFAULT 0,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_status_delete` (`status`,`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='软件升级产品';
-- 已有表升级:
-- ALTER TABLE `yz_system_software_upgrade`
-- ADD COLUMN `download_urls` text DEFAULT NULL COMMENT '多运行环境安装包地址 JSON,如 {"windows":"...","mac":"...","ubuntu":"...","linux":"..."}' AFTER `download_url`;
+69
View File
@@ -84,6 +84,7 @@ func Init(_ string) {
new(TenantCrmEntityContact),
new(TenantCrmOperateLog),
new(TenantCrmContract),
new(TenantCrmPayback),
new(ErpAccountSet),
new(ErpNormalSetting),
new(ErpCompanyContact),
@@ -147,7 +148,49 @@ func Init(_ string) {
EnsureCrmProjectDocColumn()
EnsureCrmContractTable()
EnsureCrmContractOurRoleColumn()
EnsureCrmContractTechDateColumn()
EnsureCrmProjectProductsColumn()
EnsureCrmPaybackTable()
EnsureCrmProductDevColumns()
}
// EnsureCrmPaybackTable 回款计划表建表(CREATE TABLE IF NOT EXISTS,可重复执行;
// 建表失败(如表已存在但结构不一致)时静默忽略,可用 sql/yz_backend_crm_payback.sql 手动修复)。
func EnsureCrmPaybackTable() {
sql := `CREATE TABLE IF NOT EXISTS yz_backend_crm_payback (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
contract_id bigint(20) DEFAULT NULL COMMENT '关联合同ID(yz_backend_crm_contract.id)',
contract_no varchar(50) NOT NULL DEFAULT '' COMMENT '合同编号',
contract_name varchar(100) NOT NULL DEFAULT '' COMMENT '合同名称',
customer_id bigint(20) DEFAULT NULL COMMENT '客户ID',
customer_name varchar(128) NOT NULL DEFAULT '' COMMENT '客户名称',
plan_type tinyint(4) NOT NULL DEFAULT '1' COMMENT '回款周期:1月度/2季度/3年度/4进度/5自定义',
pay_method tinyint(4) NOT NULL DEFAULT '1' COMMENT '回款方式:1对公转账/2网银转账/3现金/4支票/5支付宝/6微信/7其他',
remind_days int(11) NOT NULL DEFAULT '0' COMMENT '提前几天提醒',
owner_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
owner_user_name varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
contract_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总金额',
total_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '计划回款总额',
received_amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '已回款金额',
status tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1进行中/2已完成',
items text COMMENT '计划明细JSON数组',
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_contract (tenant_id,contract_id),
KEY idx_customer (tenant_id,customer_id),
KEY idx_status (tenant_id,status),
KEY idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM回款计划表'`
if _, err := Orm.Raw(sql).Exec(); err != nil {
// 表已存在或执行失败时忽略(完整表结构见 sql/yz_backend_crm_payback.sql)
return
}
}
// EnsureCrmContractOurRoleColumn 补齐合同表的我方角色字段(存量表已建时新增;
@@ -158,6 +201,14 @@ func EnsureCrmContractOurRoleColumn() {
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmContractTechDateColumn 补齐合同表的技术日期字段(开发/交付完成日期;
// 存量表已建时新增,表不存在或列已存在时忽略错误)。
func EnsureCrmContractTechDateColumn() {
sql := "ALTER TABLE " + new(TenantCrmContract).TableName() +
" ADD COLUMN tech_date date DEFAULT NULL COMMENT '技术日期(开发/交付完成日期;到期且回款未满则自动转执行异常)'"
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmProjectProductsColumn 补齐项目表的产品清单字段(存量表已建时新增;
// 表不存在或列已存在时忽略错误,可用 docs/sql/alter_backend_crm_project_products_column.sql 手动修复)。
func EnsureCrmProjectProductsColumn() {
@@ -221,6 +272,24 @@ func EnsureCrmProjectDocColumn() {
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmProductDevColumns 补齐产品表的软件开发字段(line_type / dev_unit_price / dev_tree);
// 表不存在或列已存在时忽略错误,可用 docs/sql/alter_backend_crm_product_dev_columns.sql 手动修复。
func EnsureCrmProductDevColumns() {
table := new(TenantCrmProduct).TableName()
cols := []string{
"line_type varchar(20) NOT NULL DEFAULT 'product' COMMENT '行类型:product成品/dev软件开发'",
"dev_unit_price decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '软件开发统一人天单价'",
"dev_tree text COMMENT '软件开发模块树JSON(叶子人天×统一人天单价)'",
}
for _, col := range cols {
sql := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s", table, col)
if _, err := Orm.Raw(sql).Exec(); err != nil {
// 列已存在或表不存在时忽略
continue
}
}
}
+1
View File
@@ -25,6 +25,7 @@ type TenantCrmContract struct {
SignDate *time.Time `orm:"column(sign_date);type(date);null" json:"sign_date"`
EffectiveDate *time.Time `orm:"column(effective_date);type(date);null" json:"effective_date"`
ExpireDate *time.Time `orm:"column(expire_date);type(date);null" json:"expire_date"`
TechDate *time.Time `orm:"column(tech_date);type(date);null" json:"tech_date"` // 技术日期(开发/交付完成日期;到期且回款未满则自动转执行异常)
Parties string `orm:"column(parties);type(text);null" json:"-"` // 各方签约主体 JSON 数组
Products string `orm:"column(products);type(text);null" json:"-"` // 产品清单 JSON 数组
HardwareAmount float64 `orm:"column(hardware_amount);digits(14);decimals(2);default(0)" json:"hardware_amount"`
+47
View File
@@ -0,0 +1,47 @@
package models
import "time"
// TenantCrmPayback 回款计划表: yz_backend_crm_payback
//
// 说明:
// - 回款计划针对于合同创建,contract_id 为关联合同(yz_backend_crm_contract.id);
// - plan_type 回款周期:1=月度 2=季度 3=年度 4=进度 5=自定义;
// - pay_method 回款方式:1=对公转账 2=网银转账 3=现金 4=支票 5=支付宝 6=微信 7=其他;
// - items 计划明细以 JSON 文本存储,结构随 plan_type 变化:
// 月度/季度/年度:{seq, name, amount, plan_date} —— 期数 + 支付金额 + 计划回款日期
// 进度: {seq, name, percent, amount, plan_date} —— 进度名称 + 进度百分比 + 进度金额 + 计划回款日期
// 自定义: {seq, name, plan_date, amount} —— 名称 + 计划日期 + 金额
// 每行另带 received_amount / received_date / status,用于「回款进度」登记实收。
// - plan_date 计划回款日期随各 plan_type 的明细行一并存储于 items JSON,无需独立列。
// - total_amount 计划回款总额;received_amount 已回款金额,均由 items 汇总。
type TenantCrmPayback struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
ContractID *uint64 `orm:"column(contract_id);null" json:"contract_id"` // 关联合同ID
ContractNo string `orm:"column(contract_no);size(50)" json:"contract_no"`
ContractName string `orm:"column(contract_name);size(100)" json:"contract_name"`
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"`
CustomerName string `orm:"column(customer_name);size(128)" json:"customer_name"`
PlanType int8 `orm:"column(plan_type);default(1)" json:"plan_type"` // 回款周期:1月度/2季度/3年度/4进度/5自定义
PayMethod int8 `orm:"column(pay_method);default(1)" json:"pay_method"` // 回款方式:1对公/2网银/3现金/4支票/5支付宝/6微信/7其他
RemindDays int `orm:"column(remind_days);default(0)" json:"remind_days"` // 提前几天提醒
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
ContractAmount float64 `orm:"column(contract_amount);digits(14);decimals(2);default(0)" json:"contract_amount"` // 合同总金额
TotalAmount float64 `orm:"column(total_amount);digits(14);decimals(2);default(0)" json:"total_amount"` // 计划回款总额
ReceivedAmount float64 `orm:"column(received_amount);digits(14);decimals(2);default(0)" json:"received_amount"` // 已回款金额
Status int8 `orm:"column(status);default(1)" json:"status"` // 1进行中/2已完成
Items string `orm:"column(items);type(text);null" json:"-"` // 计划明细 JSON 数组
Remark string `orm:"column(remark);type(text);null" json:"remark"`
ReceiptURL string `orm:"column(receipt_url);size(512);null" json:"receipt_url"` // 流水回执文件地址(选填)
ReceiptName string `orm:"column(receipt_name);size(255);null" json:"receipt_name"` // 流水回执文件名(选填)
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 *TenantCrmPayback) TableName() string {
return "yz_backend_crm_payback"
}
+6 -1
View File
@@ -1,6 +1,8 @@
package models
import "time"
import (
"time"
)
// TenantCrmProduct CRM 产品管理(产品台账/目录): yz_backend_crm_product
//
@@ -19,6 +21,9 @@ type TenantCrmProduct struct {
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"` // 税率(%)
LineType string `orm:"column(line_type);size(20);default(product)" json:"line_type"` // 行类型 product/dev
DevUnitPrice float64 `orm:"column(dev_unit_price);digits(14);decimals(2);default(0)" json:"dev_unit_price"` // 软件开发统一人天单价
DevTree string `orm:"column(dev_tree);type(text);null" json:"dev_tree"` // 软件开发模块树JSON文本
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"` // 来源项目名称
+10
View File
@@ -426,12 +426,22 @@ func registerOrganizationRoutes(module string) {
beego.Router("/backend/crm/contract/:id", &controllers.BackendCrmContractController{}, "get:Detail;put:Update;delete:Delete")
beego.Router("/backend/crm/contract/:id/status", &controllers.BackendCrmContractController{}, "post:ChangeStatus")
// CRM回款管理(针对于合同:回款计划创建 + 回款进度登记)
beego.Router("/backend/crm/payback/list", &controllers.BackendCrmPaybackController{}, "get:List")
beego.Router("/backend/crm/payback/progress/list", &controllers.BackendCrmPaybackController{}, "get:ProgressList")
beego.Router("/backend/crm/payback", &controllers.BackendCrmPaybackController{}, "post:Create")
beego.Router("/backend/crm/payback/:id", &controllers.BackendCrmPaybackController{}, "get:Detail;put:Update;delete:Delete")
beego.Router("/backend/crm/payback/:id/receive", &controllers.BackendCrmPaybackController{}, "post:Receive")
// CRM回访记录(贯穿线索/商机/项目)
beego.Router("/backend/crm/follow/list", &controllers.BackendCrmFollowController{}, "get:List")
beego.Router("/backend/crm/follow/add", &controllers.BackendCrmFollowController{}, "post:Add")
beego.Router("/backend/crm/follow/edit", &controllers.BackendCrmFollowController{}, "post:Edit")
beego.Router("/backend/crm/follow/delete", &controllers.BackendCrmFollowController{}, "post:Delete")
// CRM数据仪表盘(按时间维度聚合真实业务数据)
beego.Router("/backend/crm/dashboard", &controllers.BackendCrmDashboardController{}, "get:Summary")
// CRM附件(线索/商机/项目资料)
beego.Router("/backend/crm/attach/list", &controllers.BackendCrmAttachController{}, "get:List")
beego.Router("/backend/crm/attach/add", &controllers.BackendCrmAttachController{}, "post:Add")
-38
View File
@@ -1,38 +0,0 @@
-- 通讯录表(全局表,已从 yz_backend_erp_contact 改名):与组织架构联动,支持内部员工(自动同步)和外部联系人
-- 已合并 alter_contact_add_dept_position.sql 的 dept_name / position_id 字段
-- tenant 隔离:通过 tid 字段实现租户间数据隔离
CREATE TABLE IF NOT EXISTS `yz_backend_contact` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`employee_id` bigint(20) unsigned DEFAULT NULL COMMENT '关联员工ID(内部联系人自动关联)',
`org_id` bigint(20) unsigned DEFAULT NULL COMMENT '所属组织/部门ID(关联yz_backend_organization)',
`contact_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '联系人类型:1=内部员工 2=外部联系人',
`contact_name` varchar(64) NOT NULL COMMENT '联系人姓名',
`gender` tinyint(4) NOT NULL DEFAULT 0 COMMENT '性别:0=未知 1=男 2=女',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`work_phone` varchar(20) DEFAULT NULL COMMENT '办公电话',
`email` varchar(128) DEFAULT NULL COMMENT '邮箱',
`wechat` varchar(64) DEFAULT NULL COMMENT '微信号',
`avatar` varchar(512) DEFAULT NULL COMMENT '头像URL',
`company_name` varchar(128) DEFAULT NULL COMMENT '公司名称(外部联系人)',
`dept_name` varchar(128) DEFAULT NULL COMMENT '外部联系人手填部门名称',
`position_id` bigint(20) unsigned DEFAULT NULL COMMENT '职位ID,关联yz_backend_position',
`position_title` varchar(100) DEFAULT NULL COMMENT '职位名称',
`address` varchar(512) DEFAULT NULL COMMENT '地址',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`is_starred` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否收藏:0=否 1=是',
`sort` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '排序值',
`status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1=正常 0=停用',
`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_tid` (`tid`),
KEY `idx_employee_id` (`employee_id`),
KEY `idx_org_id` (`org_id`),
KEY `idx_contact_type` (`contact_type`),
KEY `idx_contact_name` (`contact_name`),
KEY `idx_phone` (`phone`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通讯录表';
-198
View File
@@ -1,198 +0,0 @@
-- =============================================================================
-- 联系人正式库统一:正式客户/供应商联系人通用表 yz_backend_contact_company
--
-- 目标:
-- 1. 正式客户/供应商联系人统一到 `yz_backend_contact_company`
-- (backend 端「公司联系人」通用表,命名与 yz_backend_contact 保持一致);
-- 2. 兼容历史表名 `yz_backend_erp_company_contact`:
-- - 若新表不存在、旧表存在,直接 RENAME(结构与数据 100% 保留);
-- - 若两表并存,则去重合并进新表,旧表改名为 *_bak 备份保留;
-- 3. 迁移历史死表 `yz_tenant_crm_contact`(若存在且有数据):去重并入新表,
-- 原表改名为 `yz_tenant_crm_contact_bak` 保留;
-- 4. 线索/商机联系人仍然只存过程库 `yz_backend_crm_entity_contact`(本脚本不动)。
--
-- 去重键(判定为同一联系人):tenant_id + company_type + company_id + name + mobiles
-- 说明:
-- - 脚本幂等,可重复执行;
-- - 任何迁移都是「先备份原表 / 或直接改名」再按去重键补插,绝不删除数据;
-- - 执行本脚本的同时需要部署新版后端(模型表名已指向 yz_backend_contact_company)。
-- =============================================================================
SET NAMES utf8mb4;
-- 记录初始状态
SET @has_new := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_contact_company'
);
SET @has_old_company := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact'
);
-- -----------------------------------------------------------------------------
-- 1. 新表不存在、旧表存在 → 直接改名(保留全部结构、索引、自增、数据)
-- -----------------------------------------------------------------------------
SET @sql := IF(@has_new = 0 AND @has_old_company > 0,
'RENAME TABLE `yz_backend_erp_company_contact` TO `yz_backend_contact_company`',
'SELECT ''step1: 跳过直接改名'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 重新计算状态
SET @has_new := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_contact_company'
);
SET @has_old_company := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact'
);
-- -----------------------------------------------------------------------------
-- 2. 确保正式表存在(新表也不存在、旧表也不存在时创建)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_contact_company` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`tenant_id` varchar(64) NOT NULL DEFAULT '' COMMENT '租户ID',
`company_type` varchar(20) NOT NULL DEFAULT '' COMMENT '公司类型:customer=客户,supplier=供应商',
`company_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '公司ID(客户/供应商ID)',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '联系人姓名',
`gender` tinyint(4) NOT NULL DEFAULT 0 COMMENT '性别:0-未知 1-男 2-女',
`phone` varchar(20) NOT NULL DEFAULT '' COMMENT '座机',
`mobiles` text COMMENT '手机号,JSON数组,最多5个',
`wechat` varchar(50) NOT NULL DEFAULT '' COMMENT '微信',
`qq` varchar(50) NOT NULL DEFAULT '' COMMENT 'QQ',
`dingtalk` varchar(50) NOT NULL DEFAULT '' COMMENT '钉钉',
`email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
`department` varchar(50) NOT NULL DEFAULT '' COMMENT '部门',
`position` varchar(50) NOT NULL DEFAULT '' COMMENT '职位',
`home_address` varchar(255) NOT NULL DEFAULT '' COMMENT '家庭住址',
`is_primary` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否主联系人:0-否 1-是',
`status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:0-离职 1-在职',
`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`),
KEY `idx_tenant_company` (`tenant_id`,`company_type`,`company_id`) COMMENT '按公司查询联系人',
KEY `idx_name` (`tenant_id`,`name`) COMMENT '按姓名查询',
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='正式客户/供应商联系人通用表(backend 公司联系人)';
-- -----------------------------------------------------------------------------
-- 3. 两表并存时:旧表 yz_backend_erp_company_contact 去重并入新表,再改名备份
-- -----------------------------------------------------------------------------
SET @sql := IF(@has_old_company > 0,
'INSERT INTO `yz_backend_contact_company`
(`tenant_id`,`company_type`,`company_id`,`name`,`gender`,`phone`,`mobiles`,`wechat`,`qq`,`dingtalk`,`email`,`department`,`position`,`home_address`,`is_primary`,`status`,`remark`,`create_time`,`update_time`,`delete_time`)
SELECT
CAST(o.`tenant_id` AS CHAR), o.`company_type`, o.`company_id`, o.`name`, o.`gender`, o.`phone`, o.`mobiles`,
IFNULL(o.`wechat`, ''''), IFNULL(o.`qq`, ''''), IFNULL(o.`dingtalk`, ''''), IFNULL(o.`email`, ''''),
IFNULL(o.`department`, ''''), IFNULL(o.`position`, ''''), IFNULL(o.`home_address`, ''''),
IFNULL(o.`is_primary`, 0), IFNULL(o.`status`, 1), o.`remark`,
IFNULL(o.`create_time`, NOW()), IFNULL(o.`update_time`, NOW()), o.`delete_time`
FROM `yz_backend_erp_company_contact` o
WHERE NOT EXISTS (
SELECT 1 FROM `yz_backend_contact_company` n
WHERE n.`tenant_id` = CAST(o.`tenant_id` AS CHAR)
AND n.`company_type` = o.`company_type`
AND n.`company_id` = o.`company_id`
AND n.`name` = o.`name`
AND IFNULL(n.`mobiles`, '''') = IFNULL(o.`mobiles`, '''')
)',
'SELECT ''step3: 无并存旧公司联系人表,跳过合并'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @bak_company_exists := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact_bak'
);
SET @sql := IF(@has_old_company > 0 AND @bak_company_exists = 0,
'RENAME TABLE `yz_backend_erp_company_contact` TO `yz_backend_erp_company_contact_bak`',
'SELECT ''step3: 跳过改名备份'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- -----------------------------------------------------------------------------
-- 4. 历史死表 yz_tenant_crm_contact(related_type 1=客户/2=供应商)→ 新表,原表备份
-- mobiles 由单个 mobile 转为 JSON 数组;相关字段做空值兜底。
-- -----------------------------------------------------------------------------
SET @has_dead := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_contact'
);
SET @sql := IF(@has_dead > 0,
'INSERT INTO `yz_backend_contact_company`
(`tenant_id`,`company_type`,`company_id`,`name`,`gender`,`phone`,`mobiles`,`wechat`,`qq`,`dingtalk`,`email`,`department`,`position`,`home_address`,`is_primary`,`status`,`remark`,`create_time`,`update_time`,`delete_time`)
SELECT
CAST(o.`tenant_id` AS CHAR),
IF(o.`related_type` = 2, ''supplier'', ''customer''),
o.`related_id`, o.`contact_name`, IFNULL(o.`gender`, 0), IFNULL(o.`phone`, ''''),
CASE WHEN o.`mobile` IS NULL OR o.`mobile` = '''' THEN NULL ELSE CONCAT(''["'', o.`mobile`, ''"]'') END,
'''', '''', '''', IFNULL(o.`email`, ''''), IFNULL(o.`department`, ''''), IFNULL(o.`position`, ''''), '''',
IFNULL(o.`is_primary`, 0), 1, IFNULL(o.`remark`, ''''),
IFNULL(o.`create_time`, NOW()), IFNULL(o.`update_time`, NOW()),
CASE WHEN IFNULL(o.`is_deleted`, 0) = 1 THEN NOW() ELSE NULL END
FROM `yz_tenant_crm_contact` o
WHERE o.`contact_name` IS NOT NULL AND o.`contact_name` <> ''''
AND NOT EXISTS (
SELECT 1 FROM `yz_backend_contact_company` n
WHERE n.`tenant_id` = CAST(o.`tenant_id` AS CHAR)
AND n.`company_type` = IF(o.`related_type` = 2, ''supplier'', ''customer'')
AND n.`company_id` = o.`related_id`
AND n.`name` = o.`contact_name`
AND IFNULL(n.`mobiles`, '''') = IFNULL(
CASE WHEN o.`mobile` IS NULL OR o.`mobile` = '''' THEN NULL ELSE CONCAT(''["'', o.`mobile`, ''"]'') END, '''')
)',
'SELECT ''步骤4: 无 yz_tenant_crm_contact,跳过'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @bak_dead_exists := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_contact_bak'
);
SET @sql := IF(@has_dead > 0 AND @bak_dead_exists = 0,
'RENAME TABLE `yz_tenant_crm_contact` TO `yz_tenant_crm_contact_bak`',
'SELECT ''步骤4: 跳过改名备份'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- -----------------------------------------------------------------------------
-- 5. 重复数据检测(只查询、不修改/不删除,供人工核对)
-- -----------------------------------------------------------------------------
SELECT
`tenant_id`,
`company_type`,
`company_id`,
`name`,
IFNULL(`mobiles`, '') AS `mobiles`,
COUNT(*) AS `dup_count`,
GROUP_CONCAT(`id` ORDER BY `id`) AS `dup_ids`
FROM `yz_backend_contact_company`
WHERE `delete_time` IS NULL
GROUP BY `tenant_id`, `company_type`, `company_id`, `name`, IFNULL(`mobiles`, '')
HAVING COUNT(*) > 1;
-- -----------------------------------------------------------------------------
-- 6. 迁移结果核对(新表与备份表条数)
-- -----------------------------------------------------------------------------
SET @bak_company_exists2 := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact_bak'
);
SET @bak_dead_exists2 := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_contact_bak'
);
SELECT 'yz_backend_contact_company' AS `table_name`, COUNT(*) AS `row_count`
FROM `yz_backend_contact_company`;
SET @sql := IF(@bak_company_exists2 > 0,
'SELECT ''yz_backend_erp_company_contact_bak'' AS `table_name`, COUNT(*) AS `row_count` FROM `yz_backend_erp_company_contact_bak`',
'SELECT ''yz_backend_erp_company_contact_bak(不存在)'' AS `table_name`, 0 AS `row_count`');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF(@bak_dead_exists2 > 0,
'SELECT ''yz_tenant_crm_contact_bak'' AS `table_name`, COUNT(*) AS `row_count` FROM `yz_tenant_crm_contact_bak`',
'SELECT ''yz_tenant_crm_contact_bak(不存在)'' AS `table_name`, 0 AS `row_count`');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-6
View File
@@ -1,6 +0,0 @@
-- 通讯录表新增字段:外部联系人手填部门、内部员工职位关联
-- 对应模型:go/models/contact.go BackendErpContact
ALTER TABLE `yz_backend_contact`
ADD COLUMN `dept_name` VARCHAR(128) NULL COMMENT '外部联系人手填部门名称' AFTER `company_name`,
ADD COLUMN `position_id` BIGINT UNSIGNED NULL COMMENT '职位ID,关联 yz_backend_position' AFTER `dept_name`;
-42
View File
@@ -1,42 +0,0 @@
-- 通讯录表新增 QQ 字段
-- 对应模型:go/models/contact.go BackendErpContact
--
-- 说明:历史迁移 alter_contact_rename_to_global.sql 把表名从 yz_backend_erp_contact
-- 改名为 yz_backend_contact,但模型 BackendErpContact.TableName() 仍返回旧表名。
-- 为避免不同环境表名不一致导致字段加不上,这里按实际存在的表逐个补齐,可重复执行。
SET @db := DATABASE();
-- 旧表名(模型当前使用的表名)
SET @tbl := 'yz_backend_erp_contact';
SET @table_exists := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl
);
SET @column_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = 'qq'
);
SET @sql := IF(@table_exists > 0 AND @column_exists = 0,
CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `qq` VARCHAR(20) NULL COMMENT ''QQ号'' AFTER `wechat`'),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 新表名(执行过改名迁移的环境)
SET @tbl := 'yz_backend_contact';
SET @table_exists := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl
);
SET @column_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = 'qq'
);
SET @sql := IF(@table_exists > 0 AND @column_exists = 0,
CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `qq` VARCHAR(20) NULL COMMENT ''QQ号'' AFTER `wechat`'),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-5
View File
@@ -1,5 +0,0 @@
-- 通讯录表全局化:从 ERP 模块独立为全局基础功能,表名去掉 erp 前缀
-- 对应模型:go/models/contact.go(BackendErpContact)
-- RENAME 会保留全部数据与索引,对存量环境执行本脚本即可完成迁移
RENAME TABLE `yz_backend_erp_contact` TO `yz_backend_contact`;
-40
View File
@@ -1,40 +0,0 @@
-- =============================================================================
-- 跟进记录(回访)增加「创建阶段」字段 stage
--
-- 目的:
-- 过程数据(跟进记录)不做搬迁,按 线索 → 商机 → 项目 链路累积展示;
-- stage 记录该条记录创建时所处的阶段,用于详情列表标明数据来自哪个阶段。
--
-- 取值:1=线索,2=商机,3=项目
-- 说明:
-- - 脚本幂等:列已存在则跳过新增;
-- - 回填只在首次新增列时执行一次(历史数据按当前 related_type 近似回填)。
-- =============================================================================
SET NAMES utf8mb4;
SET @has_stage := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'yz_backend_crm_follow'
AND COLUMN_NAME = 'stage'
);
-- 1. 新增列(已存在则跳过)
SET @sql := IF(@has_stage = 0,
'ALTER TABLE `yz_backend_crm_follow` ADD COLUMN `stage` tinyint(4) NOT NULL DEFAULT 1 COMMENT ''创建阶段:1线索/2商机/3项目'' AFTER `related_name`',
'SELECT ''skip: stage 列已存在'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. 历史数据回填(仅在首次新增列时执行;按当前 related_type 近似推断)
SET @sql := IF(@has_stage = 0,
'UPDATE `yz_backend_crm_follow` SET `stage` = `related_type` WHERE `related_type` IN (2, 3)',
'SELECT ''skip: 回填已完成过'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. 核对:各阶段记录数
SELECT `stage`, COUNT(*) AS `cnt`
FROM `yz_backend_crm_follow`
WHERE `delete_time` IS NULL
GROUP BY `stage`
ORDER BY `stage`;
@@ -1,42 +0,0 @@
-- =============================================================================
-- 为 CRM 业务管线(线索/商机/项目)增加「对接人职位」字段 contact_position
--
-- 说明:
-- - 幂等:列已存在时自动跳过,不会报 Duplicate column 错误;
-- - 只增加字段,不改动任何已有数据;
-- - 若尚未建表,直接执行 yz_backend_crm_pipeline.sql 已包含该字段,无需本脚本。
-- =============================================================================
SET NAMES utf8mb4;
-- 1. 线索
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_clue' AND COLUMN_NAME = 'contact_position');
SET @sql := IF(@has_col = 0,
'ALTER TABLE `yz_backend_crm_clue` ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '''' COMMENT ''对接人职位'' AFTER `contact_person`',
'SELECT ''skip: yz_backend_crm_clue.contact_position 已存在'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. 商机
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_business' AND COLUMN_NAME = 'contact_position');
SET @sql := IF(@has_col = 0,
'ALTER TABLE `yz_backend_crm_business` ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '''' COMMENT ''对接人职位'' AFTER `contact_person`',
'SELECT ''skip: yz_backend_crm_business.contact_position 已存在'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. 项目
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project' AND COLUMN_NAME = 'contact_position');
SET @sql := IF(@has_col = 0,
'ALTER TABLE `yz_backend_crm_project` ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '''' COMMENT ''对接人职位'' AFTER `contact_person`',
'SELECT ''skip: yz_backend_crm_project.contact_position 已存在'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 4. 核对:三张表的 contact_position 字段
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('yz_backend_crm_clue', 'yz_backend_crm_business', 'yz_backend_crm_project')
AND COLUMN_NAME = 'contact_position'
ORDER BY TABLE_NAME;
@@ -1,14 +0,0 @@
-- -----------------------------------------------------------------------------
-- 项目文档(CRM 项目 ↔ OA 文档库绑定)
--
-- 在项目表上增加文档库文件夹分类ID:
-- 指向 yz_backend_oa_doc_category 中「共享文档 / 项目文档 / {项目名称}」这条分类。
-- 项目建立时自动在文档库中创建同名文件夹并回写该列;
-- 存量项目(为 0)在打开「项目文档」或项目详情时自动补建。
-- 项目改名时同步重命名文档库文件夹。
--
-- 注:新建库时该列已包含在 yz_backend_crm_pipeline.sql 中,此脚本用于已有库升级。
-- 后端启动时也会自动补列(models.EnsureCrmProjectDocColumn),可不必手工执行。
-- -----------------------------------------------------------------------------
ALTER TABLE `yz_backend_crm_project`
ADD COLUMN `doc_category_id` bigint(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联OA文档库项目文件夹分类ID(共享文档/项目文档/{项目名称})';
@@ -1,69 +0,0 @@
-- =============================================================================
-- CRM 业务管线表名规范化:yz_tenant_crm_* → yz_backend_crm_*
--
-- 目的:与 yz_backend_erp_*、yz_backend_contact_company 等保持统一前缀,便于查找维护。
--
-- 映射:
-- yz_tenant_crm_clue → yz_backend_crm_clue
-- yz_tenant_crm_business → yz_backend_crm_business
-- yz_tenant_crm_project → yz_backend_crm_project
-- yz_tenant_crm_follow → yz_backend_crm_follow
-- yz_tenant_crm_attach → yz_backend_crm_attach
-- yz_tenant_crm_entity_contact → yz_backend_crm_entity_contact
-- yz_tenant_crm_operate_log → yz_backend_crm_operate_log
--
-- 说明:
-- - RENAME TABLE 是原子改名,数据、索引、自增全部原样保留,不会丢数据;
-- - 幂等:新表已存在或旧表不存在时自动跳过;
-- - 注意:需与新版后端一起发布(模型表名已指向 yz_backend_crm_*)。
-- =============================================================================
SET NAMES utf8mb4;
-- 1. 线索
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_clue');
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_clue');
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_clue` TO `yz_backend_crm_clue`', 'SELECT ''skip: clue'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. 商机
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_business');
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_business');
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_business` TO `yz_backend_crm_business`', 'SELECT ''skip: business'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. 项目
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_project');
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project');
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_project` TO `yz_backend_crm_project`', 'SELECT ''skip: project'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 4. 回访/跟进记录
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_follow');
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_follow');
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_follow` TO `yz_backend_crm_follow`', 'SELECT ''skip: follow'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 5. 附件
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_attach');
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_attach');
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_attach` TO `yz_backend_crm_attach`', 'SELECT ''skip: attach'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 6. 联系人
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_entity_contact');
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_entity_contact');
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_entity_contact` TO `yz_backend_crm_entity_contact`', 'SELECT ''skip: entity_contact'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 7. 操作日志
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_operate_log');
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_operate_log');
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_operate_log` TO `yz_backend_crm_operate_log`', 'SELECT ''skip: operate_log'' AS note');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 8. 核对结果
SELECT TABLE_NAME, TABLE_ROWS, CREATE_TIME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'yz_%crm_%'
ORDER BY TABLE_NAME;
@@ -1,8 +0,0 @@
-- 员工档案主表增加证照字段:身份证正反面、学历照片。
-- 仅在已执行过 yz_backend_employee_file.sql 建表时执行本脚本;
-- 未建表的库直接执行最新版 yz_backend_employee_file.sql 即可,无需本脚本。
ALTER TABLE `yz_backend_employee_file`
ADD COLUMN `id_card_front` varchar(500) NOT NULL DEFAULT '' COMMENT '身份证正面(人像面)图片URL' AFTER `id_card`,
ADD COLUMN `id_card_back` varchar(500) NOT NULL DEFAULT '' COMMENT '身份证反面(国徽面)图片URL' AFTER `id_card_front`,
ADD COLUMN `education_photo` varchar(500) NOT NULL DEFAULT '' COMMENT '学历照片URL' AFTER `id_card_back`;
-8
View File
@@ -1,8 +0,0 @@
-- 为设备表添加心跳时间字段,用于实时判断设备是否在线
-- 在线判断逻辑:last_heartbeat_at 在 5 分钟以内 = 在线,否则 = 离线
ALTER TABLE `yz_platform_cursor_equipment`
ADD COLUMN `last_heartbeat_at` DATETIME NULL DEFAULT NULL COMMENT '最后心跳时间(客户端定期上报)' AFTER `update_time`;
-- 可选:加索引,便于管理后台统计在线设备数
ALTER TABLE `yz_platform_cursor_equipment`
ADD INDEX `idx_last_heartbeat_at` (`last_heartbeat_at`);
-14
View File
@@ -1,14 +0,0 @@
-- 记事本表增加置顶字段(兼容已有数据库)
-- 已存在字段时请跳过对应 ALTER 语句。
ALTER TABLE `yz_platform_notebook`
ADD COLUMN `pinned` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否置顶 0-否 1-是' AFTER `content`;
ALTER TABLE `yz_platform_notebook`
ADD KEY `idx_pinned` (`pinned`);
ALTER TABLE `yz_backend_notebook`
ADD COLUMN `pinned` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否置顶 0-否 1-是' AFTER `content`;
ALTER TABLE `yz_backend_notebook`
ADD KEY `idx_pinned` (`pinned`);
-19
View File
@@ -1,19 +0,0 @@
-- 日程表增加"顺延到下一天"相关字段(兼容已有数据库)
-- 已存在字段时请跳过对应 ALTER 语句。
-- 作用:当天没做完的日程可一键顺延,原日程标记完成(carry_over=1),
-- 并在目标日期自动生成一条待办(source_id 指向源头日程)继续跟进。
ALTER TABLE `yz_backend_oa_schedule`
ADD COLUMN `source_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '顺延来源日程ID 0-原始日程' AFTER `finish_time`;
ALTER TABLE `yz_backend_oa_schedule`
ADD COLUMN `carry_over` tinyint NOT NULL DEFAULT '0' COMMENT '是否已顺延到下一天 0-否 1-是' AFTER `source_id`;
ALTER TABLE `yz_backend_oa_schedule`
ADD COLUMN `carry_count` int NOT NULL DEFAULT '0' COMMENT '累计顺延次数' AFTER `carry_over`;
ALTER TABLE `yz_backend_oa_schedule`
ADD COLUMN `carry_to_date` varchar(10) NOT NULL DEFAULT '' COMMENT '顺延目标日期(YYYY-MM-DD)' AFTER `carry_count`;
ALTER TABLE `yz_backend_oa_schedule`
ADD KEY `idx_source_id` (`source_id`);
@@ -1,78 +0,0 @@
-- ============================================================
-- 密码存储:单账号结构 -> 一个平台挂载多个账号(accounts JSON)
--
-- 使用说明:
-- 1. 先备份:
-- mysqldump -u用户 -p 库名 yz_platform_password_store yz_backend_password_store > pwd_backup.sql
-- 2. 按顺序执行下面的「第 1 步」「第 2 步」「第 3 步」。
-- 3. 如果某条 ALTER 报 "Duplicate column name 'accounts'",说明该列已存在,
-- 忽略该条错误继续往下执行即可。
-- 4. 「第 4 步」删除旧列不可逆,确认页面数据显示正常后再手动执行。
-- ============================================================
-- ---------- 第 1 步:新增 accounts 列 ----------
-- 报 Duplicate column name 表示已加过,忽略即可。
ALTER TABLE `yz_platform_password_store`
ADD COLUMN `accounts` longtext NULL
COMMENT '账号列表JSON: [{"username":"","password":"","registration_info":"","remark":""}]'
AFTER `url`;
ALTER TABLE `yz_backend_password_store`
ADD COLUMN `accounts` longtext NULL
COMMENT '账号列表JSON: [{"username":"","password":"","registration_info":"","remark":""}]'
AFTER `url`;
-- 旧表可能没有 delete_time 列,模型里有该字段,一并补上。
-- 报 Duplicate column name 同样忽略。
ALTER TABLE `yz_platform_password_store` ADD COLUMN `delete_time` datetime NULL;
ALTER TABLE `yz_backend_password_store` ADD COLUMN `delete_time` datetime NULL;
-- ---------- 第 2 步:把旧的单账号数据搬进 accounts ----------
-- 只处理 accounts 仍为空、且旧列确实有内容的行,包装成只含一条账号的 JSON 数组。
-- 如果报 Unknown column 'username',说明旧列已经删掉了,无需迁移,跳过这两条。
UPDATE `yz_platform_password_store`
SET `accounts` = JSON_ARRAY(JSON_OBJECT(
'username', IFNULL(`username`, ''),
'password', IFNULL(`password`, ''),
'registration_info', IFNULL(`registration_info`, ''),
'remark', ''
))
WHERE (`accounts` IS NULL OR `accounts` = '' OR `accounts` = '[]')
AND (IFNULL(`username`, '') <> ''
OR IFNULL(`password`, '') <> ''
OR IFNULL(`registration_info`, '') <> '');
UPDATE `yz_backend_password_store`
SET `accounts` = JSON_ARRAY(JSON_OBJECT(
'username', IFNULL(`username`, ''),
'password', IFNULL(`password`, ''),
'registration_info', IFNULL(`registration_info`, ''),
'remark', ''
))
WHERE (`accounts` IS NULL OR `accounts` = '' OR `accounts` = '[]')
AND (IFNULL(`username`, '') <> ''
OR IFNULL(`password`, '') <> ''
OR IFNULL(`registration_info`, '') <> '');
-- ---------- 第 3 步:accounts 为空的行统一补成空数组 ----------
UPDATE `yz_platform_password_store` SET `accounts` = '[]'
WHERE `accounts` IS NULL OR `accounts` = '';
UPDATE `yz_backend_password_store` SET `accounts` = '[]'
WHERE `accounts` IS NULL OR `accounts` = '';
-- ---------- 第 4 步(可选,不可逆):删除旧的单账号列 ----------
-- 先在页面上确认历史数据都能正常显示,再手动执行下面两条。
-- 旧表这三列是 NOT NULL DEFAULT '',不删也不影响新代码运行。
-- ALTER TABLE `yz_platform_password_store`
-- DROP COLUMN `username`, DROP COLUMN `password`, DROP COLUMN `registration_info`;
-- ALTER TABLE `yz_backend_password_store`
-- DROP COLUMN `username`, DROP COLUMN `password`, DROP COLUMN `registration_info`;
@@ -1,12 +0,0 @@
-- 报销明细:每张发票对应一个确认金额,与 receipt_file_ids 顺序一一对应(逗号分隔,空位可留空)
-- 示例:receipt_file_ids=357,358 receipt_amounts=98.00,145.00
-- 若已有 receipt_urls 列,用这一条:
ALTER TABLE `yz_backend_reimbursement_items`
ADD COLUMN `receipt_amounts` varchar(500) NOT NULL DEFAULT '' COMMENT '票据金额,逗号分隔,与 receipt_file_ids 对应' AFTER `receipt_urls`;
-- 若上面报错 Unknown column 'receipt_urls',改用这一条(放在 receipt_file_ids 后面):
-- ALTER TABLE `yz_backend_reimbursement_items`
-- ADD COLUMN `receipt_amounts` varchar(500) NOT NULL DEFAULT '' COMMENT '票据金额,逗号分隔,与 receipt_file_ids 对应' AFTER `receipt_file_ids`;
-- 若报错 Duplicate column name 'receipt_amounts',说明字段已存在,无需重复执行。
@@ -1,3 +0,0 @@
-- 报销明细发票关联统一文件表 yz_system_files(逗号分隔多个文件 ID)
ALTER TABLE `yz_backend_reimbursement_items`
ADD COLUMN `receipt_file_ids` varchar(500) NOT NULL DEFAULT '' COMMENT '关联 yz_system_files.id,逗号分隔' AFTER `receipt_flag`;
@@ -1,3 +0,0 @@
-- 票据地址(多个 URL 逗号分隔,与 receipt_file_ids 对应)
ALTER TABLE `yz_backend_reimbursement_items`
ADD COLUMN `receipt_urls` varchar(500) NOT NULL DEFAULT '' COMMENT '票据文件地址,逗号分隔' AFTER `receipt_file_ids`;
@@ -1,12 +0,0 @@
-- 报销明细发票:统一使用 receipt_file_ids,移除 receipt_file_id
-- 若已存在 receipt_file_id 列,先合并数据再删除
UPDATE `yz_backend_reimbursement_items`
SET `receipt_file_ids` = CAST(`receipt_file_id` AS CHAR)
WHERE (`receipt_file_ids` = '' OR `receipt_file_ids` IS NULL)
AND `receipt_file_id` IS NOT NULL
AND `receipt_file_id` > 0;
ALTER TABLE `yz_backend_reimbursement_items`
DROP INDEX `idx_receipt_file_id`,
DROP COLUMN `receipt_file_id`;
@@ -1,6 +0,0 @@
-- receipt_url 重命名为 receipt_urls(与代码字段一致)
ALTER TABLE `yz_backend_reimbursement_items`
CHANGE COLUMN `receipt_url` `receipt_urls` varchar(500) NOT NULL DEFAULT '' COMMENT '票据文件地址,逗号分隔';
ALTER TABLE `yz_backend_reimbursement_items`
DROP INDEX `idx_receipt_url`;
@@ -1,7 +0,0 @@
-- 移除报销主表不再使用的字段:报销事由(title)、主表费用类型(expense_type)
-- 费用明细表 yz_backend_reimbursement_items 的 expense_type 保留不变
ALTER TABLE `yz_backend_reimbursements`
DROP INDEX `idx_expense_type`,
DROP COLUMN `title`,
DROP COLUMN `expense_type`;
-39
View File
@@ -1,39 +0,0 @@
-- 租户域名表增加「自有域名绑定 + 自动 HTTPS」所需字段(兼容已有数据库)
-- 已存在字段时请跳过对应 ALTER 语句。
-- 对应模型:go/models/system_tenant_domain.go
-- 域名类型:1-平台二级域名(历史数据默认值即为 1)2-租户自有域名
ALTER TABLE `yz_system_tenant_domain`
ADD COLUMN `domain_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '域名类型 1-平台二级域名 2-自有域名' AFTER `tid`;
-- 证书状态:0-待处理 1-签发中 2-已签发 3-签发失败
ALTER TABLE `yz_system_tenant_domain`
ADD COLUMN `ssl_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '证书状态 0-待处理 1-签发中 2-已签发 3-失败' AFTER `status`;
ALTER TABLE `yz_system_tenant_domain`
ADD COLUMN `ssl_expire_at` datetime DEFAULT NULL COMMENT '证书到期时间' AFTER `ssl_status`;
ALTER TABLE `yz_system_tenant_domain`
ADD COLUMN `ssl_error` varchar(500) DEFAULT NULL COMMENT '最近一次签发失败原因' AFTER `ssl_expire_at`;
-- DNS 解析校验:0-未检测 1-已通过 2-未通过
ALTER TABLE `yz_system_tenant_domain`
ADD COLUMN `verify_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'DNS校验 0-未检测 1-已通过 2-未通过' AFTER `ssl_error`;
ALTER TABLE `yz_system_tenant_domain`
ADD COLUMN `verify_msg` varchar(500) DEFAULT NULL COMMENT '最近一次DNS检测说明' AFTER `verify_status`;
ALTER TABLE `yz_system_tenant_domain`
ADD COLUMN `last_verify_at` datetime DEFAULT NULL COMMENT '最近一次DNS检测时间' AFTER `verify_msg`;
-- 索引:按 Host 查租户是官网渲染的热路径;域名类型用于「同类型只能一条」的判断
-- 注意:这里用普通索引而非唯一索引,避免历史脏数据(含软删重复域名)导致 ALTER 失败;
-- 域名唯一性由应用层 bindCustom / apply 接口校验。
ALTER TABLE `yz_system_tenant_domain`
ADD KEY `idx_full_domain` (`full_domain`);
ALTER TABLE `yz_system_tenant_domain`
ADD KEY `idx_tid_domain_type` (`tid`, `domain_type`);
ALTER TABLE `yz_system_tenant_domain`
ADD KEY `idx_ssl_status` (`ssl_status`);
-7
View File
@@ -1,7 +0,0 @@
-- 清理旧版本自动插入的文章分类(旧代码 cmsEnsureTables 每次请求都会调
-- EnsureCmsArticleDefaultCategories,向 yz_cms_article_category 插入
-- tid=0 的「文章中心」「新闻中心」,导致刷新页面分类不断增多)。
-- 新代码已移除该逻辑,分类完全由后台手动维护。
-- 执行前请先备份;如需保留其中一条,请自行注释对应语句。
DELETE FROM yz_cms_article_category WHERE tid = 0 AND `name` IN ('文章中心', '新闻中心');
-102
View File
@@ -1,102 +0,0 @@
-- =============================================================================
-- 修正「负责人 / 回访人 / 上传人」姓名:把登录账号刷成真实姓名
--
-- 背景:
-- 历史代码把 JWT 里的 Username(登录账号,如 hero920103)写进了
-- owner_user_name / last_owner_name 等字段,导致界面显示账号而不是人名。
-- 后端已改为按 uid 查 yz_system_tenant_user.name 写入真实姓名,
-- 本脚本用于修正存量数据。
--
-- 说明:
-- - 按 tenant_id + owner_user_id(uid) 关联 yz_system_tenant_user 取 name;
-- - 只更新「能匹配到用户且姓名非空」的记录,不动其它数据;
-- - 幂等,可重复执行(姓名已一致的行不会被更新)。
-- =============================================================================
SET NAMES utf8mb4;
-- 1. 客户:负责人姓名
UPDATE `yz_backend_erp_customer` c
JOIN `yz_system_tenant_user` u
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
SET c.`owner_user_name` = u.`name`
WHERE c.`owner_user_id` IS NOT NULL AND c.`owner_user_id` <> ''
AND u.`name` IS NOT NULL AND u.`name` <> ''
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
-- 2. 客户:公海记录里的「原负责人」姓名
UPDATE `yz_backend_erp_customer` c
JOIN `yz_system_tenant_user` u
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
SET c.`last_owner_name` = u.`name`
WHERE c.`owner_user_id` IS NOT NULL AND c.`owner_user_id` <> ''
AND u.`name` IS NOT NULL AND u.`name` <> ''
AND c.`last_owner_name` IS NOT NULL AND c.`last_owner_name` <> ''
AND c.`last_owner_name` <> u.`name`;
-- 3. 线索:负责人姓名
UPDATE `yz_backend_crm_clue` c
JOIN `yz_system_tenant_user` u
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
SET c.`owner_user_name` = u.`name`
WHERE c.`owner_user_id` <> ''
AND u.`name` IS NOT NULL AND u.`name` <> ''
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
-- 4. 商机:负责人姓名
UPDATE `yz_backend_crm_business` c
JOIN `yz_system_tenant_user` u
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
SET c.`owner_user_name` = u.`name`
WHERE c.`owner_user_id` <> ''
AND u.`name` IS NOT NULL AND u.`name` <> ''
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
-- 5. 项目:负责人姓名
UPDATE `yz_backend_crm_project` c
JOIN `yz_system_tenant_user` u
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
SET c.`owner_user_name` = u.`name`
WHERE c.`owner_user_id` <> ''
AND u.`name` IS NOT NULL AND u.`name` <> ''
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
-- 6. 回访记录:回访人姓名
UPDATE `yz_backend_crm_follow` c
JOIN `yz_system_tenant_user` u
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
SET c.`owner_user_name` = u.`name`
WHERE c.`owner_user_id` <> ''
AND u.`name` IS NOT NULL AND u.`name` <> ''
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
-- 7. 附件:上传人姓名
UPDATE `yz_backend_crm_attach` c
JOIN `yz_system_tenant_user` u
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
AND u.`uid` = CAST(c.`uploader_id` AS UNSIGNED)
SET c.`uploader_name` = u.`name`
WHERE c.`uploader_id` <> ''
AND u.`name` IS NOT NULL AND u.`name` <> ''
AND (c.`uploader_name` IS NULL OR c.`uploader_name` <> u.`name`);
-- 8. 操作日志:操作人姓名
UPDATE `yz_backend_crm_operate_log` c
JOIN `yz_system_tenant_user` u
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
AND u.`uid` = CAST(c.`operator_id` AS UNSIGNED)
SET c.`operator_name` = u.`name`
WHERE c.`operator_id` <> ''
AND u.`name` IS NOT NULL AND u.`name` <> ''
AND (c.`operator_name` IS NULL OR c.`operator_name` <> u.`name`);
-- 9. 核对:客户负责人分布
SELECT `owner_user_id`, `owner_user_name`, COUNT(*) AS `cnt`
FROM `yz_backend_erp_customer`
GROUP BY `owner_user_id`, `owner_user_name`;
@@ -1,193 +0,0 @@
-- 组织架构模块:表名去掉 erp 字样,改为租户端通用命名
-- yz_backend_erp_organization -> yz_backend_organization
-- yz_backend_erp_employee -> yz_backend_employee
-- yz_backend_erp_position -> yz_backend_position
--
-- 组织架构由进销存(ERP)与办公自动化(OA)共用同一份数据,通过 tid 实现租户间隔离。
-- 执行前请确认当前数据库字符集为 utf8mb4,并先备份这三张表。
--
-- 说明:脚本可重复执行。如果旧表不存在(全新环境),RENAME 会被跳过,
-- 后面的 CREATE TABLE IF NOT EXISTS 会建出目标表结构。
-- ---------------------------------------------------------------------------
-- 1. 重命名已有表(仅当旧表存在且新表不存在时执行)
-- ---------------------------------------------------------------------------
SET @schema := DATABASE();
SET @sql := IF(
(SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_erp_organization') = 1
AND (SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_organization') = 0,
'RENAME TABLE `yz_backend_erp_organization` TO `yz_backend_organization`',
'SELECT "skip: yz_backend_organization"'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_erp_employee') = 1
AND (SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_employee') = 0,
'RENAME TABLE `yz_backend_erp_employee` TO `yz_backend_employee`',
'SELECT "skip: yz_backend_employee"'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_erp_position') = 1
AND (SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_position') = 0,
'RENAME TABLE `yz_backend_erp_position` TO `yz_backend_position`',
'SELECT "skip: yz_backend_position"'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------------
-- 2. 全新环境建表(旧表不存在时生效)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_organization` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '租户ID(数据隔离)',
`org_name` varchar(128) NOT NULL DEFAULT '' COMMENT '组织名称',
`org_code` varchar(64) NOT NULL DEFAULT '' COMMENT '组织编码(租户内唯一)',
`parent_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '上级组织ID,0为顶级',
`sort` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '排序,越小越靠前',
`leader_id` bigint(20) unsigned DEFAULT NULL COMMENT '负责人(员工ID)',
`is_company` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否企业单位:1-是 0-部门',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1-启用 0-禁用',
`remark` varchar(512) DEFAULT NULL 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`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_tid_code` (`tid`,`org_code`) USING BTREE,
KEY `idx_parent_id` (`parent_id`) USING BTREE,
KEY `idx_is_company` (`is_company`) USING BTREE,
KEY `idx_status` (`status`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='组织架构表(ERP与OA共用)';
CREATE TABLE IF NOT EXISTS `yz_backend_employee` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) DEFAULT NULL COMMENT '租户ID(数据隔离)',
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '登录账号(租户内唯一)',
`password` varchar(64) NOT NULL DEFAULT '' COMMENT '密码(sha256 hex)',
`name` varchar(30) NOT NULL DEFAULT '' COMMENT '姓名',
`gender` tinyint(1) NOT NULL DEFAULT '0' COMMENT '性别:0-未知 1-男 2-女',
`birthday` date DEFAULT NULL COMMENT '生日',
`affiliate_unit` varchar(100) DEFAULT NULL COMMENT '隶属单位(组织ID)',
`department` varchar(50) DEFAULT NULL COMMENT '部门(组织ID)',
`position` varchar(50) DEFAULT NULL COMMENT '职位',
`education` varchar(20) DEFAULT NULL COMMENT '学历',
`nation` varchar(20) DEFAULT NULL COMMENT '民族',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`wechat` varchar(50) DEFAULT NULL COMMENT '微信',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`home_address` varchar(255) DEFAULT NULL COMMENT '家庭住址',
`account_status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '账号状态:1-启用 0-禁用 2-离职',
`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`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_tid_account` (`tid`,`account`) USING BTREE,
KEY `idx_department` (`department`) USING BTREE,
KEY `idx_affiliate_unit` (`affiliate_unit`) USING BTREE,
KEY `idx_account_status` (`account_status`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='员工信息表(ERP与OA共用)';
CREATE TABLE IF NOT EXISTS `yz_backend_position` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '租户ID(数据隔离)',
`department_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '所属部门(组织ID)',
`position_code` varchar(50) NOT NULL DEFAULT '' COMMENT '职位编码(租户内唯一)',
`position_name` varchar(100) NOT NULL DEFAULT '' COMMENT '职位名称',
`position_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '职位类型:0-普通 1-主管 2-负责人',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1-启用 0-禁用',
`sort` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '排序,越小越靠前',
`remark` varchar(512) DEFAULT NULL 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`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_tid_code` (`tid`,`position_code`) USING BTREE,
KEY `idx_department_id` (`department_id`) USING BTREE,
KEY `idx_status` (`status`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='职位表(ERP与OA共用)';
-- ---------------------------------------------------------------------------
-- 3. 补齐旧表缺失的字段与索引(重命名后的表可能来自更早的版本)
-- ---------------------------------------------------------------------------
-- yz_backend_position 新增 remark / delete_time
SET @sql := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_position'
AND COLUMN_NAME = 'remark') = 0,
'ALTER TABLE `yz_backend_position` ADD COLUMN `remark` varchar(512) DEFAULT NULL COMMENT ''备注'' AFTER `sort`',
'SELECT "skip: yz_backend_position.remark"'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_position'
AND COLUMN_NAME = 'delete_time') = 0,
'ALTER TABLE `yz_backend_position` ADD COLUMN `delete_time` datetime DEFAULT NULL COMMENT ''删除时间(软删除)''',
'SELECT "skip: yz_backend_position.delete_time"'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_position'
AND INDEX_NAME = 'idx_tid_code') = 0,
'ALTER TABLE `yz_backend_position` ADD INDEX `idx_tid_code` (`tid`,`position_code`) USING BTREE',
'SELECT "skip: yz_backend_position.idx_tid_code"'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_organization'
AND INDEX_NAME = 'idx_tid_code') = 0,
'ALTER TABLE `yz_backend_organization` ADD INDEX `idx_tid_code` (`tid`,`org_code`) USING BTREE',
'SELECT "skip: yz_backend_organization.idx_tid_code"'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_employee'
AND INDEX_NAME = 'idx_tid_account') = 0,
'ALTER TABLE `yz_backend_employee` ADD INDEX `idx_tid_account` (`tid`,`account`) USING BTREE',
'SELECT "skip: yz_backend_employee.idx_tid_account"'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------------
-- 4. 组织架构设置存放在 yz_backend_normal_setting,按 backend_org_settings_{tid} 区分租户
-- 这里只保证该表存在,具体记录由后端首次保存设置时写入。
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_normal_setting` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(128) NOT NULL DEFAULT '' COMMENT '配置名称',
`value` text COMMENT '配置内容(JSON)',
`code` varchar(64) NOT NULL DEFAULT '' COMMENT '配置标识',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_code` (`code`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='管理端通用配置表';
-27
View File
@@ -1,27 +0,0 @@
-- 员工档案菜单(租户端):挂在办公自动化模块目录下。
--
-- 前置:yz_system_menu 中已存在 path 为 /apps/oa 的模块目录菜单;
-- 若不存在,脚本会自动创建。
-- views 字段:[2] 租户端。type 字段:2-页面。
--
-- 脚本可重复执行:按 path 判重,已存在的菜单会更新组件路径与标题。
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT 0, '办公自动化', '/apps/oa', '', 'Document', 31, 1, 1, '[2]', 1, '办公自动化模块'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa'
);
SET @oa_pid := (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/apps/oa' ORDER BY `id` LIMIT 1);
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT @oa_pid, '员工档案', '/apps/oa/employeefile', '/apps/oa/employeefile/index.vue', 'Postcard', 4, 1, 1, '[2]', 2, '一人一档的人事档案管理'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa/employeefile'
);
-- 修正已有菜单的组件路径(历史数据可能指向旧组件)
UPDATE `yz_system_menu` SET `component_path` = '/apps/oa/employeefile/index.vue', `type` = 2, `views` = '[2]'
WHERE `path` = '/apps/oa/employeefile';
-69
View File
@@ -1,69 +0,0 @@
-- 薪酬管理菜单(租户端):挂在“办公自动化”模块目录下。
--
-- 说明:
-- 1. 本脚本只创建/修正 yz_system_menu 菜单记录,不涉及薪酬业务数据表。
-- 2. 路由由前端根据登录接口返回的菜单数据动态注册;
-- 不需要在 backend/src/router/index.js 中增加静态业务路由。
-- 3. 执行完成后,请在角色菜单权限关联表中为目标角色授予“薪酬管理”菜单权限,
-- 然后退出重登或清理菜单缓存。
--
-- views: [2] = 租户端
-- type: 1 = 目录,2 = 页面
-- 脚本可重复执行:通过 path 判重,已有记录会被修正为当前配置。
-- 确保“办公自动化”父级目录存在。
INSERT INTO `yz_system_menu`
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT
0, '办公自动化', '/apps/oa', '', 'Document', 31, 1, 1, '[2]', 1, '办公自动化模块'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM (SELECT * FROM `yz_system_menu`) AS t
WHERE t.`path` = '/apps/oa'
);
SET @oa_pid := (
SELECT `id`
FROM `yz_system_menu`
WHERE `path` = '/apps/oa'
ORDER BY `id`
LIMIT 1
);
-- 新增“薪酬管理”页面菜单。
INSERT INTO `yz_system_menu`
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT
@oa_pid,
'薪酬管理',
'/apps/oa/compensation',
'/apps/oa/compensation/index.vue',
'Money',
6,
1,
1,
'[2]',
2,
'员工薪资档案与月度工资核算'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM (SELECT * FROM `yz_system_menu`) AS t
WHERE t.`path` = '/apps/oa/compensation'
);
-- 修正历史或手工新增菜单的动态组件路径及基础属性。
UPDATE `yz_system_menu`
SET
`pid` = @oa_pid,
`title` = '薪酬管理',
`component_path` = '/apps/oa/compensation/index.vue',
`icon` = 'Money',
`sort` = 6,
`status` = 1,
`is_visible` = 1,
`views` = '[2]',
`type` = 2,
`remark` = '员工薪资档案与月度工资核算'
WHERE `path` = '/apps/oa/compensation';
-69
View File
@@ -1,69 +0,0 @@
-- 通知公告菜单(租户端):挂在“办公自动化”模块目录下。
--
-- 说明:
-- 1. 本脚本只创建/修正 yz_system_menu 菜单记录,不涉及通知公告业务数据表。
-- 2. 路由由前端根据登录接口返回的菜单数据动态注册;
-- 不需要在 backend/src/router/index.js 中增加静态业务路由。
-- 3. 执行完成后,请在角色菜单权限关联表中为目标角色授予“通知公告”菜单权限,
-- 然后退出重登或清理菜单缓存。
--
-- views: [2] = 租户端
-- type: 1 = 目录,2 = 页面
-- 脚本可重复执行:通过 path 判重,已有记录会被修正为当前配置。
-- 确保“办公自动化”父级目录存在。
INSERT INTO `yz_system_menu`
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT
0, '办公自动化', '/apps/oa', '', 'Document', 31, 1, 1, '[2]', 1, '办公自动化模块'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM (SELECT * FROM `yz_system_menu`) AS t
WHERE t.`path` = '/apps/oa'
);
SET @oa_pid := (
SELECT `id`
FROM `yz_system_menu`
WHERE `path` = '/apps/oa'
ORDER BY `id`
LIMIT 1
);
-- 新增“通知公告”页面菜单。
INSERT INTO `yz_system_menu`
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT
@oa_pid,
'通知公告',
'/apps/oa/notice',
'/apps/oa/notice/index.vue',
'Bell',
7,
1,
1,
'[2]',
2,
'通知公告发布与管理'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM (SELECT * FROM `yz_system_menu`) AS t
WHERE t.`path` = '/apps/oa/notice'
);
-- 修正历史或手工新增菜单的动态组件路径及基础属性。
UPDATE `yz_system_menu`
SET
`pid` = @oa_pid,
`title` = '通知公告',
`component_path` = '/apps/oa/notice/index.vue',
`icon` = 'Bell',
`sort` = 7,
`status` = 1,
`is_visible` = 1,
`views` = '[2]',
`type` = 2,
`remark` = '通知公告发布与管理'
WHERE `path` = '/apps/oa/notice';
-91
View File
@@ -1,91 +0,0 @@
-- 全局基础设置:组织架构、人员管理、职位管理
-- 三项功能使用统一组织数据,统一归入全局“基础设置”,不再隶属 OA 或 ERP。
-- views:[2] 租户后台;type:1-目录,2-页面。
-- 可重复执行。最后会删除 OA / ERP 下的历史重复页面菜单。
-- 1. 确保全局“基础设置”目录存在
INSERT INTO `yz_system_menu`
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT
0, '基础设置', '/basic-settings', '', 'Setting', 20, 1, 1, '[2]', 1, '全局基础数据维护'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t
WHERE t.`path` = '/basic-settings'
);
SET @basic_settings_pid := (
SELECT `id` FROM `yz_system_menu`
WHERE `path` = '/basic-settings'
ORDER BY `id` LIMIT 1
);
-- 2. 创建全局入口
INSERT INTO `yz_system_menu`
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT
@basic_settings_pid, '组织架构', '/basic-settings/organization',
'/basicSettings/organization/index.vue', 'OfficeBuilding', 1, 1, 1, '[2]', 2,
'全局组织架构维护,与所有业务模块共用'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t
WHERE t.`path` = '/basic-settings/organization'
);
INSERT INTO `yz_system_menu`
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT
@basic_settings_pid, '人员管理', '/basic-settings/employee',
'/basicSettings/employee/index.vue', 'User', 2, 1, 1, '[2]', 2,
'全局人员维护,与所有业务模块共用'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t
WHERE t.`path` = '/basic-settings/employee'
);
INSERT INTO `yz_system_menu`
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
SELECT
@basic_settings_pid, '职位管理', '/basic-settings/position',
'/basicSettings/position/index.vue', 'Postcard', 3, 1, 1, '[2]', 2,
'全局职位维护,与所有业务模块共用'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t
WHERE t.`path` = '/basic-settings/position'
);
-- 3. 修正已存在的全局入口
UPDATE `yz_system_menu`
SET `pid` = @basic_settings_pid, `title` = '组织架构',
`component_path` = '/basicSettings/organization/index.vue',
`icon` = 'OfficeBuilding', `sort` = 1, `status` = 1, `is_visible` = 1,
`views` = '[2]', `type` = 2, `remark` = '全局组织架构维护,与所有业务模块共用'
WHERE `path` = '/basic-settings/organization';
UPDATE `yz_system_menu`
SET `pid` = @basic_settings_pid, `title` = '人员管理',
`component_path` = '/basicSettings/employee/index.vue',
`icon` = 'User', `sort` = 2, `status` = 1, `is_visible` = 1,
`views` = '[2]', `type` = 2, `remark` = '全局人员维护,与所有业务模块共用'
WHERE `path` = '/basic-settings/employee';
UPDATE `yz_system_menu`
SET `pid` = @basic_settings_pid, `title` = '职位管理',
`component_path` = '/basicSettings/position/index.vue',
`icon` = 'Postcard', `sort` = 3, `status` = 1, `is_visible` = 1,
`views` = '[2]', `type` = 2, `remark` = '全局职位维护,与所有业务模块共用'
WHERE `path` = '/basic-settings/position';
-- 4. 删除历史 OA / ERP 下的重复页面入口,不删除模块目录本身。
DELETE FROM `yz_system_menu`
WHERE `path` IN (
'/apps/erp/organization',
'/apps/erp/employee',
'/apps/erp/position',
'/apps/oa/organization',
'/apps/oa/employee',
'/apps/oa/position'
);
-12
View File
@@ -1,12 +0,0 @@
-- 升级已有的 yz_system_reminderlist 表,添加 batch_id 以及发送目标相关字段
ALTER TABLE `yz_system_reminderlist`
ADD COLUMN `batch_id` varchar(64) NOT NULL DEFAULT '' COMMENT '批次号/分组ID' AFTER `delete_time`,
ADD COLUMN `target_type` varchar(32) NOT NULL DEFAULT '' COMMENT '发送目标类型: platform, tenant_all, role, tenant' AFTER `batch_id`,
ADD COLUMN `target_role_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标角色ID' AFTER `target_type`,
ADD COLUMN `target_tenant_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标租户ID' AFTER `target_role_id`,
ADD INDEX `idx_batch` (`batch_id`);
-- 初始化已有记录的 batch_id (如果存在空值,使用 create_time 和 sender_id 进行分组初始化)
UPDATE `yz_system_reminderlist`
SET `batch_id` = CONCAT(UNIX_TIMESTAMP(COALESCE(`create_time`, NOW())), '_', `sender_id`)
WHERE `batch_id` = '' OR `batch_id` IS NULL;
-53
View File
@@ -1,53 +0,0 @@
-- =============================================================================
-- CRM 合同管理:进度式创建(合同信息 / 产品清单),绑定项目或无头合同
-- 对应租户端 backend 页面:/apps/crm/contract
-- 说明:
-- 1. project_id 为空即为「无头合同」,否则为「项目合同」;
-- 2. our_role:我方角色 1=甲方 2=乙方 3=丙方 4=丁方(当前租户扮演的一方,默认乙方);
-- party_count:2=双方(甲乙)3=三方(甲乙丙)4=四方(甲乙丙丁);
-- 3. parties / products 以 JSON 文本存储;
-- 4. 各部分金额冗余为表字段,由后端按 products 重算,便于统计与排序;
-- 5. status:1=草稿(进度式保存中)2=已完成 3=已作废;step 记录创建进度。
-- ⚠️ 安全说明:本脚本只做 CREATE TABLE IF NOT EXISTS,不删除或覆盖任何已有数据,可重复执行。
-- =============================================================================
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `yz_backend_crm_contract` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
`contract_no` varchar(50) NOT NULL DEFAULT '' COMMENT '合同编号',
`contract_name` varchar(100) NOT NULL COMMENT '合同名称',
`contract_category` varchar(20) NOT NULL DEFAULT '' COMMENT '合同分类:1开发/2服务/3销售/4租赁/5采购/6运维/7咨询/8其他',
`our_role` tinyint(4) NOT NULL DEFAULT '2' COMMENT '我方角色:1甲方/2乙方/3丙方/4丁方(当前租户扮演的一方)',
`party_count` tinyint(4) NOT NULL DEFAULT '2' COMMENT '合同形式:2双方(甲乙)/3三方(甲乙丙)/4四方(甲乙丙丁)',
`project_id` bigint(20) DEFAULT NULL COMMENT '关联项目ID(yz_backend_crm_project.id),空=无头合同',
`project_name` varchar(100) NOT NULL DEFAULT '' COMMENT '项目名称',
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '项目负责人用户ID',
`owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '项目负责人姓名(默认为创建人)',
`sign_date` date DEFAULT NULL COMMENT '签订日期',
`effective_date` date DEFAULT NULL COMMENT '生效日期',
`expire_date` date DEFAULT NULL COMMENT '结束日期',
`parties` text COMMENT '各方签约主体 JSON:[{role,ref_type,ref_id,ref_name,signer_name,signer_phone}]',
`products` text COMMENT '产品清单 JSON:[{name,category,spec,unit,quantity,price,cost_price,remark}]',
`hardware_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '硬件部分金额(元)',
`software_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '软件部分金额(元)',
`other_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '其他部分金额(服务/开发/其他)(元)',
`total_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总金额(元)',
`total_cost` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '产品总成本(元)',
`total_profit` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总利润(元)',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1草稿/2已完成/3已作废/4履约中/5执行异常',
`step` tinyint(4) NOT NULL DEFAULT '1' COMMENT '进度式创建步骤:1合同信息/2产品清单',
`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_contract_name` (`tenant_id`,`contract_name`),
KEY `idx_contract_no` (`tenant_id`,`contract_no`),
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合同表';
-229
View File
@@ -1,229 +0,0 @@
-- =============================================================================
-- CRM 业务管线:线索 → 商机 → 项目,回访贯穿全流程
-- 对应租户端 backend 页面:/apps/crm/clue、/apps/crm/business、/apps/crm/project、/apps/crm/follow
-- 说明:
-- 1. 线索的「客户名称」暂不关联客户管理(线索阶段尚无价值),
-- 线索转化为商机时才把客户名称落地为正式客户(yz_backend_erp_customer)。
-- 2. 线索转化商机后会被锁定(locked=1),后续操作都在商机进行。
-- 3. 商机转化项目后同样锁定(locked=1)。
-- 4. 回访/跟进记录(yz_backend_crm_follow)与附件、联系人、操作日志均为通用多态表,
-- 通过 related_type(1线索/2商机/3项目) + related_id 关联。
-- ⚠️ 安全说明(重要):
-- 本脚本只做 CREATE TABLE IF NOT EXISTS,**不会删除或覆盖任何已有数据**,可重复执行。
-- 如需给已存在的表新增字段,请勿重跑本脚本,改用对应的 alter_*.sql 增量脚本。
-- =============================================================================
SET NAMES utf8mb4;
-- -----------------------------------------------------------------------------
-- 线索表
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_crm_clue` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
`clue_name` varchar(100) NOT NULL COMMENT '线索名称',
`customer_name` varchar(100) NOT NULL DEFAULT '' COMMENT '客户名称(线索阶段不关联客户管理)',
`source` varchar(50) NOT NULL DEFAULT '' COMMENT '客户来源(1官网咨询/2电话咨询/3朋友介绍/4展会/5广告投放/6陌生拜访/7其他)',
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
`owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
`industry` varchar(50) NOT NULL DEFAULT '' COMMENT '客户行业',
`next_contact_time` datetime DEFAULT NULL COMMENT '下次联系时间',
`clue_level` varchar(20) NOT NULL DEFAULT '2' COMMENT '客户级别:1重点/2普通/3非优先',
`contact_person` varchar(50) NOT NULL DEFAULT '' COMMENT '客户对接人',
`contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位',
`contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
`contact_wechat` varchar(64) NOT NULL DEFAULT '' COMMENT '对接人微信',
`contact_qq` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人QQ',
`address` varchar(255) NOT NULL DEFAULT '' COMMENT '公司地址',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1跟进中/2已转化/3已关闭',
`locked` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否锁定:0否/1是(转化后锁定不可编辑)',
`business_id` bigint(20) DEFAULT NULL COMMENT '转化后的商机ID',
`convert_time` datetime DEFAULT NULL 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_clue_name` (`tenant_id`,`clue_name`),
KEY `idx_owner` (`tenant_id`,`owner_user_id`),
KEY `idx_status` (`tenant_id`,`status`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM线索表';
-- -----------------------------------------------------------------------------
-- 商机表
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_crm_business` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
`business_name` varchar(100) NOT NULL COMMENT '商机名称',
`clue_id` bigint(20) DEFAULT NULL COMMENT '来源线索ID',
`customer_id` bigint(20) DEFAULT NULL COMMENT '正式客户ID(yz_backend_erp_customer.id)',
`customer_name` varchar(100) NOT NULL DEFAULT '' COMMENT '客户名称',
`source` varchar(50) NOT NULL DEFAULT '' COMMENT '客户来源',
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
`owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
`industry` varchar(50) NOT NULL DEFAULT '' COMMENT '客户行业',
`stage` varchar(20) NOT NULL DEFAULT '1' COMMENT '商机阶段:1初步接洽/2需求确认/3方案报价/4商务谈判/5赢单/6输单',
`amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '预计金额(元)',
`expect_deal_date` date DEFAULT NULL COMMENT '预计成交日期',
`next_contact_time` datetime DEFAULT NULL COMMENT '下次联系时间',
`level` varchar(20) NOT NULL DEFAULT '2' COMMENT '商机级别:1重点/2普通/3非优先',
`contact_person` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人',
`contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位',
`contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
`contact_wechat` varchar(64) NOT NULL DEFAULT '' COMMENT '对接人微信',
`contact_qq` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人QQ',
`address` varchar(255) NOT NULL DEFAULT '' COMMENT '客户地址',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1跟进中/2已转化项目/3已关闭',
`locked` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否锁定:0否/1是(转化项目后锁定不可编辑)',
`project_id` bigint(20) DEFAULT NULL COMMENT '转化后的项目ID',
`convert_time` datetime DEFAULT NULL 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_business_name` (`tenant_id`,`business_name`),
KEY `idx_clue` (`clue_id`),
KEY `idx_customer` (`customer_id`),
KEY `idx_owner` (`tenant_id`,`owner_user_id`),
KEY `idx_status` (`tenant_id`,`status`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM商机表';
-- -----------------------------------------------------------------------------
-- 项目表
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_crm_project` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
`project_name` varchar(100) NOT NULL COMMENT '项目名称',
`project_no` varchar(50) NOT NULL DEFAULT '' COMMENT '项目编号',
`business_id` bigint(20) DEFAULT NULL COMMENT '来源商机ID',
`customer_id` bigint(20) DEFAULT NULL COMMENT '客户ID',
`customer_name` varchar(100) NOT NULL DEFAULT '' COMMENT '客户名称',
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
`owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
`industry` varchar(50) NOT NULL DEFAULT '' COMMENT '客户行业',
`amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '项目金额(元)',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1未开始/2进行中/3已完成/4已暂停',
`start_date` date DEFAULT NULL COMMENT '开始日期',
`end_date` date DEFAULT NULL COMMENT '结束日期',
`contact_person` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人',
`contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位',
`contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
`address` varchar(255) NOT NULL DEFAULT '' COMMENT '项目地址',
`remark` text COMMENT '备注',
`doc_category_id` bigint(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联OA文档库项目文件夹分类ID(共享文档/项目文档/{项目名称})',
`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_project_name` (`tenant_id`,`project_name`),
KEY `idx_business` (`business_id`),
KEY `idx_owner` (`tenant_id`,`owner_user_id`),
KEY `idx_status` (`tenant_id`,`status`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM项目表';
-- -----------------------------------------------------------------------------
-- 回访/跟进记录表(贯穿线索、商机、项目)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_crm_follow` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
`related_name` varchar(100) NOT NULL DEFAULT '' COMMENT '关联对象名称(冗余,便于回访列表展示)',
`stage` tinyint(4) NOT NULL DEFAULT '1' COMMENT '创建阶段:1线索/2商机/3项目',
`follow_type` varchar(20) NOT NULL DEFAULT '1' COMMENT '回访方式:1电话/2微信/3上门/4邮件/5其他',
`follow_time` datetime DEFAULT NULL COMMENT '回访时间',
`content` text COMMENT '回访内容',
`next_contact_time` datetime DEFAULT NULL COMMENT '下次联系时间',
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '回访人用户ID',
`owner_user_name` varchar(128) NOT NULL DEFAULT '' 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`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_related` (`tenant_id`,`related_type`,`related_id`),
KEY `idx_follow_time` (`tenant_id`,`follow_time`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM回访/跟进记录表';
-- -----------------------------------------------------------------------------
-- 附件表(多态:线索/商机/项目的资料)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_crm_attach` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
`file_id` bigint(20) DEFAULT NULL COMMENT '文件ID(yz_system_file.id,可空)',
`file_name` varchar(255) NOT NULL DEFAULT '' COMMENT '文件名',
`file_url` varchar(500) NOT NULL DEFAULT '' COMMENT '文件访问地址',
`file_size` bigint(20) NOT NULL DEFAULT '0' COMMENT '文件大小(字节)',
`uploader_id` varchar(64) NOT NULL DEFAULT '' COMMENT '上传人用户ID',
`uploader_name` varchar(128) NOT NULL DEFAULT '' 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`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_related` (`tenant_id`,`related_type`,`related_id`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM附件表';
-- -----------------------------------------------------------------------------
-- 联系人表(多态:线索/商机/项目的对接人列表)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_crm_entity_contact` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
`contact_name` varchar(50) NOT NULL COMMENT '联系人姓名',
`position` varchar(50) NOT NULL DEFAULT '' COMMENT '职位',
`mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号',
`wechat` varchar(64) NOT NULL DEFAULT '' COMMENT '微信',
`qq` varchar(20) NOT NULL DEFAULT '' COMMENT 'QQ',
`email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
`is_primary` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否主联系人:0否/1是',
`remark` varchar(500) NOT NULL DEFAULT '' 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`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_related` (`tenant_id`,`related_type`,`related_id`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM联系人表(线索/商机/项目)';
-- -----------------------------------------------------------------------------
-- 操作日志表(多态:记录线索/商机/项目的关键操作,用于详情页操作日志 Tab)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_backend_crm_operate_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
`action` varchar(50) NOT NULL DEFAULT '' COMMENT '操作动作:create/update/convert/delete 等',
`content` varchar(500) NOT NULL DEFAULT '' COMMENT '操作描述',
`operator_id` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人用户ID',
`operator_name` varchar(128) NOT NULL DEFAULT '' COMMENT '操作人姓名',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_related` (`tenant_id`,`related_type`,`related_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM操作日志表';
SET FOREIGN_KEY_CHECKS = 1;
-70
View File
@@ -1,70 +0,0 @@
-- 员工档案表(租户端 OA 人事档案)
-- 在租户业务库执行以下语句创建表结构。
-- 数据按租户 tid 隔离;档案与员工(yz_backend_employee)一对一,
-- 子记录表统一存放教育经历 / 工作经历 / 合同信息 / 证照附件。
-- ---------------------------------------------------------------------------
-- 1. 员工档案主表
-- ---------------------------------------------------------------------------
CREATE TABLE `yz_backend_employee_file` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int NOT NULL DEFAULT '0' COMMENT '租户ID',
`employee_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '员工ID(yz_backend_employee.id)',
`file_no` varchar(50) NOT NULL DEFAULT '' COMMENT '档案编号(留空自动生成EF+时间戳)',
`id_card` varchar(30) NOT NULL DEFAULT '' COMMENT '身份证号',
`id_card_front` varchar(500) NOT NULL DEFAULT '' COMMENT '身份证正面(人像面)图片URL',
`id_card_back` varchar(500) NOT NULL DEFAULT '' COMMENT '身份证反面(国徽面)图片URL',
`education_photo` varchar(500) NOT NULL DEFAULT '' COMMENT '学历照片URL',
`political_status` varchar(30) NOT NULL DEFAULT '' COMMENT '政治面貌',
`marital_status` tinyint NOT NULL DEFAULT '0' COMMENT '婚姻状况 0-未知 1-未婚 2-已婚 3-离异 4-丧偶',
`native_place` varchar(100) NOT NULL DEFAULT '' COMMENT '籍贯',
`household_address` varchar(255) NOT NULL DEFAULT '' COMMENT '户籍地址',
`current_address` varchar(255) NOT NULL DEFAULT '' COMMENT '现居住址',
`emergency_contact` varchar(50) NOT NULL DEFAULT '' COMMENT '紧急联系人',
`emergency_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '紧急联系电话',
`emergency_relation` varchar(30) NOT NULL DEFAULT '' COMMENT '与本人关系',
`work_email` varchar(100) NOT NULL DEFAULT '' COMMENT '工作邮箱',
`hire_date` date DEFAULT NULL COMMENT '入职日期',
`regular_date` date DEFAULT NULL COMMENT '转正日期',
`leave_date` date DEFAULT NULL COMMENT '离职日期',
`employment_status` tinyint NOT NULL DEFAULT '2' COMMENT '在职状态 1-试用 2-正式 3-离职',
`remark` text NULL COMMENT '备注',
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0-否 1-是',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_tid_employee` (`tid`,`employee_id`),
KEY `idx_file_no` (`file_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='员工档案主表';
-- ---------------------------------------------------------------------------
-- 2. 员工档案子记录表(教育经历/工作经历/合同信息/证照附件)
-- type 字段区分记录类型,title/sub_title/extra 的语义随类型变化:
-- 教育经历(1):title=学校 sub_title=专业 extra=学历
-- 工作经历(2):title=公司 sub_title=职位 extra=所在部门
-- 合同信息(3):title=合同名称 sub_title=合同类型 extra=签订主体
-- 证照附件(4):title=证照名称 sub_title=证件号码 extra=''
-- ---------------------------------------------------------------------------
CREATE TABLE `yz_backend_employee_file_record` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int NOT NULL DEFAULT '0' COMMENT '租户ID',
`employee_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '员工ID(yz_backend_employee.id)',
`type` tinyint NOT NULL DEFAULT '1' COMMENT '记录类型 1-教育经历 2-工作经历 3-合同信息 4-证照附件',
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '名称(学校/公司/合同/证照)',
`sub_title` varchar(100) NOT NULL DEFAULT '' COMMENT '次级信息(专业/职位/合同类型/证件号码)',
`extra` varchar(100) NOT NULL DEFAULT '' COMMENT '附加信息(学历/部门/签订主体)',
`start_date` date DEFAULT NULL COMMENT '开始日期',
`end_date` date DEFAULT NULL COMMENT '结束日期',
`description` text NULL COMMENT '描述',
`attachment_url` varchar(500) NOT NULL DEFAULT '' COMMENT '附件地址(证照图片等)',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序(越小越靠前)',
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0-否 1-是',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
KEY `idx_tid_employee_type` (`tid`,`employee_id`,`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='员工档案子记录表';
-25
View File
@@ -1,25 +0,0 @@
-- OA 通知公告表
-- 在租户业务库执行以下语句创建表结构。
-- 数据按租户 tid 隔离;发布后对租户内所有后台用户可见。
CREATE TABLE `yz_backend_oa_notice` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int NOT NULL DEFAULT '0' COMMENT '租户ID',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '公告标题',
`content` text NULL COMMENT '公告内容',
`notice_type` tinyint NOT NULL DEFAULT '0' COMMENT '类型 0-通知 1-公告 2-活动',
`level` tinyint NOT NULL DEFAULT '0' COMMENT '紧急程度 0-普通 1-重要 2-紧急',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态 0-草稿 1-已发布 2-已下架',
`is_top` tinyint NOT NULL DEFAULT '0' COMMENT '是否置顶 0-否 1-是',
`publish_time` datetime DEFAULT NULL COMMENT '发布时间',
`publisher_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '发布人ID',
`publisher_name` varchar(100) NOT NULL DEFAULT '' COMMENT '发布人姓名',
`read_count` int NOT NULL DEFAULT '0' COMMENT '阅读次数',
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0-否 1-是',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
KEY `idx_tid_status` (`tid`,`status`),
KEY `idx_tid_top` (`tid`,`is_top`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA通知公告表';
-32
View File
@@ -1,32 +0,0 @@
-- OA 日程管理表(日历待办)
-- 在租户业务库执行以下语句创建表结构。
-- 数据按租户 tid + 用户 user_id 双重隔离,仅创建者本人可见与操作。
CREATE TABLE `yz_backend_oa_schedule` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int NOT NULL DEFAULT '0' COMMENT '租户ID',
`user_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '所属用户ID',
`user_name` varchar(100) NOT NULL DEFAULT '' COMMENT '所属用户名',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '日程标题',
`content` text NULL COMMENT '日程内容/备注',
`schedule_date` date NOT NULL COMMENT '日程日期(YYYY-MM-DD)',
`start_time` varchar(8) NOT NULL DEFAULT '' COMMENT '开始时间 HH:mm(全天日程为空)',
`end_time` varchar(8) NOT NULL DEFAULT '' COMMENT '结束时间 HH:mm(全天日程为空)',
`all_day` tinyint NOT NULL DEFAULT '1' COMMENT '是否全天 0-否 1-是',
`color` varchar(20) NOT NULL DEFAULT '#409EFF' COMMENT '标签颜色',
`priority` tinyint NOT NULL DEFAULT '0' COMMENT '优先级 0-普通 1-重要 2-紧急',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态 0-待办 1-完成',
`finish_time` datetime DEFAULT NULL COMMENT '完成时间',
`source_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '顺延来源日程ID 0-原始日程',
`carry_over` tinyint NOT NULL DEFAULT '0' COMMENT '是否已顺延到下一天 0-否 1-是',
`carry_count` int NOT NULL DEFAULT '0' COMMENT '累计顺延次数',
`carry_to_date` varchar(10) NOT NULL DEFAULT '' COMMENT '顺延目标日期(YYYY-MM-DD)',
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0-否 1-是',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
KEY `idx_tid_user_date` (`tid`,`user_id`,`schedule_date`),
KEY `idx_schedule_date` (`schedule_date`),
KEY `idx_source_id` (`source_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA日程管理表(日历待办)';
-69
View File
@@ -1,69 +0,0 @@
-- 全局组织架构相关表:组织机构、员工、职位
-- 说明:组织架构已从 ERP 模块独立为全局基础功能,表名不带 erp 前缀
-- tenant 隔离:通过 tid 字段实现租户间数据隔离
-- 对应模型:go/models/erp.go(BackendErpOrganization / BackendErpEmployee / BackendErpPosition)
-- DDL 与线上 go-platform 库实际结构一致(2026-08 导出)
-- 组织架构表(OA 组织机构、通讯录、组织机构管理页依赖)
CREATE TABLE IF NOT EXISTS `yz_backend_organization` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`tid` bigint(20) unsigned NOT NULL COMMENT '租户ID',
`org_name` varchar(128) NOT NULL COMMENT '组织/部门名称',
`org_code` varchar(64) NOT NULL COMMENT '组织/部门编码',
`parent_id` bigint(20) unsigned DEFAULT '0' COMMENT '上级组织ID',
`sort` int(10) unsigned DEFAULT '0' COMMENT '排序号',
`leader_id` bigint(20) unsigned DEFAULT NULL COMMENT '部门负责人ID',
`is_company` int(11) DEFAULT '0' COMMENT '是否是公司 0-不是 1-是',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1-启用,2-停用,0-删除',
`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 '删除时间',
`remark` varchar(512) DEFAULT NULL COMMENT '备注信息',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_tid_code` (`tid`,`org_code`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='组织架构表';
-- 员工信息表(account 全局唯一)
CREATE TABLE IF NOT EXISTS `yz_backend_employee` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`tid` int(11) DEFAULT NULL COMMENT '租户ID',
`account` varchar(50) NOT NULL COMMENT '员工账号',
`password` varchar(64) NOT NULL DEFAULT '' COMMENT '密码',
`name` varchar(30) NOT NULL COMMENT '员工姓名',
`gender` tinyint(4) NOT NULL DEFAULT '0' COMMENT '性别 0=未知 1=男 2=女',
`birthday` date DEFAULT NULL COMMENT '生日',
`affiliate_unit` varchar(100) DEFAULT NULL COMMENT '隶属单位',
`department` varchar(50) DEFAULT NULL COMMENT '部门',
`position` varchar(50) DEFAULT NULL COMMENT '职位',
`education` varchar(20) DEFAULT NULL COMMENT '学历',
`nation` varchar(20) DEFAULT NULL COMMENT '民族',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`wechat` varchar(50) DEFAULT NULL COMMENT '微信号',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`home_address` varchar(255) DEFAULT NULL COMMENT '家庭住址',
`account_status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '账号状态 0=禁用 1=正常 2=离职',
`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`) USING BTREE,
UNIQUE KEY `uk_account` (`account`) USING BTREE,
KEY `idx_tid_account` (`tid`,`account`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='员工信息表';
-- 职位表
CREATE TABLE IF NOT EXISTS `yz_backend_position` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`tid` bigint(20) unsigned NOT NULL COMMENT '租户ID',
`department_id` bigint(20) unsigned NOT NULL COMMENT '部门ID',
`position_code` varchar(50) NOT NULL COMMENT '职位编码',
`position_name` varchar(100) NOT NULL COMMENT '职位名称',
`position_type` tinyint(4) DEFAULT '0' COMMENT '职位类型:0-普通职位 1-管理职位 2-技术职位',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:0-禁用 1-启用',
`sort` int(10) unsigned DEFAULT '0' COMMENT '排序',
`remark` varchar(512) DEFAULT NULL 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`) USING BTREE,
KEY `idx_tid_code` (`tid`,`position_code`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='职位表';
@@ -1,34 +0,0 @@
-- 报销费用类型表
-- 每个租户维护自己的费用类型,uid 保留用于数据隔离扩展。
CREATE TABLE IF NOT EXISTS `yz_backend_reimbursement_expense_types` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '费用类型名称',
`sort_order` int(11) NOT NULL DEFAULT '0' COMMENT '排序值,越小越靠前',
`is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用:0-禁用 1-启用',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_tid_name` (`tid`, `name`),
KEY `idx_uid` (`uid`) USING BTREE,
KEY `idx_sort_order` (`sort_order`) USING BTREE,
KEY `idx_is_active` (`is_active`) USING BTREE,
KEY `idx_is_deleted` (`is_deleted`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='报销费用类型表';
-- 可按需为已有租户初始化默认类型:
-- INSERT INTO `yz_backend_reimbursement_expense_types`
-- (`tid`, `name`, `sort_order`)
-- VALUES
-- (1, '差旅费', 10),
-- (1, '交通费', 20),
-- (1, '餐饮费', 30),
-- (1, '办公费', 40),
-- (1, '通讯费', 50),
-- (1, '业务招待费', 60),
-- (1, '培训费', 70),
-- (1, '其他', 999);
-107
View File
@@ -1,107 +0,0 @@
-- 报销模块表结构
-- 执行前请确认当前数据库字符集为 utf8mb4
CREATE TABLE IF NOT EXISTS `yz_backend_approval_flows` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`flow_name` varchar(100) NOT NULL DEFAULT '' COMMENT '流程名称',
`step_order` tinyint(1) NOT NULL DEFAULT '0' COMMENT '步骤顺序(从1开始)',
`approver_role` varchar(50) NOT NULL DEFAULT '' COMMENT '审批角色(如: dept_manager, finance, gm)',
`approver_rule` varchar(200) NOT NULL DEFAULT '' COMMENT '审批人规则(如: 部门主管, 财务总监)',
`timeout_hours` int(11) NOT NULL DEFAULT '48' COMMENT '超时提醒时间(小时)',
`is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用:0-禁用 1-启用',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_flow_name` (`flow_name`) USING BTREE,
KEY `idx_step_order` (`step_order`) USING BTREE,
KEY `idx_is_active` (`is_active`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='审批流程配置表';
CREATE TABLE IF NOT EXISTS `yz_backend_reimbursements` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '申请人ID',
`department_id` int(11) unsigned DEFAULT '0' COMMENT '部门ID',
`total_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '总金额',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态:0-草稿 1-审批中 2-已通过 3-已驳回 4-已撤回 5-已打款',
`apply_date` date NOT NULL COMMENT '申请日期',
`description` text COMMENT '备注说明',
`current_approver_id` int(11) unsigned DEFAULT '0' COMMENT '当前审批人ID',
`current_step` tinyint(1) NOT NULL DEFAULT '0' COMMENT '当前审批步骤(0:未提交)',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_user_id` (`user_id`) USING BTREE,
KEY `idx_department_id` (`department_id`) USING BTREE,
KEY `idx_status` (`status`) USING BTREE,
KEY `idx_apply_date` (`apply_date`) USING BTREE,
KEY `idx_current_approver_id` (`current_approver_id`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE,
KEY `idx_update_time` (`update_time`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='报销单主表';
CREATE TABLE IF NOT EXISTS `yz_backend_reimbursement_items` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`reimbursement_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '关联报销单ID',
`expense_type` varchar(50) NOT NULL DEFAULT '' COMMENT '费用类型(具体子类)',
`expense_date` date NOT NULL COMMENT '费用发生日期',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '金额',
`quantity` int(11) NOT NULL DEFAULT '1' COMMENT '数量',
`unit_price` decimal(10,2) DEFAULT '0.00' COMMENT '单价',
`description` varchar(200) NOT NULL DEFAULT '' COMMENT '费用描述',
`receipt_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否有发票:0-否 1-是',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_reimbursement_id` (`reimbursement_id`) USING BTREE,
KEY `idx_expense_type` (`expense_type`) USING BTREE,
KEY `idx_expense_date` (`expense_date`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='报销费用明细表';
CREATE TABLE IF NOT EXISTS `yz_backend_approval_records` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tid` int(11) NOT NULL DEFAULT '0' COMMENT '租户ID',
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`reimbursement_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '关联报销单ID',
`approver_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '审批人ID',
`approver_name` varchar(100) NOT NULL DEFAULT '' COMMENT '审批人姓名(冗余)',
`action` tinyint(1) NOT NULL DEFAULT '0' COMMENT '操作:1-提交 2-通过 3-驳回 4-转审 5-撤回',
`comment` text COMMENT '审批意见',
`step` tinyint(1) NOT NULL DEFAULT '0' COMMENT '审批步骤序号',
`prev_step` tinyint(1) NOT NULL DEFAULT '0' COMMENT '上一步骤序号',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除:0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_reimbursement_id` (`reimbursement_id`) USING BTREE,
KEY `idx_approver_id` (`approver_id`) USING BTREE,
KEY `idx_action` (`action`) USING BTREE,
KEY `idx_step` (`step`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE,
KEY `idx_delete_time` (`delete_time`) USING BTREE,
KEY `idx_tid` (`tid`) USING BTREE,
KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='审批记录表';
-84
View File
@@ -1,84 +0,0 @@
-- =====================================================================
-- 多租户官网标签模板引擎 - 数据库脚本
-- 说明:本脚本只交付,不代执行,请在数据库后台手动运行
-- 新建表统一使用 yz_cms_frontend_ 前缀(官网前台专用)
-- =====================================================================
-- 1. 模板登记表(platform 端登记,供租户选用)
CREATE TABLE IF NOT EXISTS `yz_cms_frontend_template` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`code` varchar(50) NOT NULL DEFAULT '' COMMENT '模板编码,对应 themes/{code}/ 目录',
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '模板名称',
`description` varchar(500) NOT NULL DEFAULT '' COMMENT '模板描述',
`preview` varchar(500) NOT NULL DEFAULT '' COMMENT '预览图地址',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
`delete_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_code` (`code`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官网模板登记表';
-- 2. 官网 Banner 表(按租户隔离)
CREATE TABLE IF NOT EXISTS `yz_cms_frontend_banner` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`tid` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
`desc` varchar(500) NOT NULL DEFAULT '' COMMENT '简介',
`image` varchar(500) NOT NULL DEFAULT '' COMMENT '图片地址',
`url` varchar(500) NOT NULL DEFAULT '' COMMENT '跳转地址',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
`delete_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_tid_status` (`tid`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官网Banner表';
-- 3. 友情链接表(按租户隔离)
CREATE TABLE IF NOT EXISTS `yz_cms_frontend_friendlink` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`tid` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`link_name` varchar(100) NOT NULL DEFAULT '' COMMENT '链接名称',
`link_url` varchar(500) NOT NULL DEFAULT '' COMMENT '链接地址',
`link_logo` varchar(500) NOT NULL DEFAULT '' COMMENT 'Logo地址',
`description` varchar(500) NOT NULL DEFAULT '' COMMENT '描述',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0禁用',
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
`delete_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_tid_status` (`tid`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官网友情链接表';
-- 4. 单页表(按租户隔离,path 租户内唯一)
CREATE TABLE IF NOT EXISTS `yz_cms_frontend_onepage` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`tid` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '单页标题',
`path` varchar(100) NOT NULL DEFAULT '' COMMENT '访问路径标识,租户内唯一,如 about',
`content` longtext COMMENT '富文本内容',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
`delete_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_tid_path` (`tid`, `path`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官网单页表';
-- 5. 租户站点设置表新增模板编码字段(租户选用的官网模板)
ALTER TABLE `yz_system_tenant_site_setting`
ADD COLUMN `template_code` varchar(50) NOT NULL DEFAULT '' COMMENT '选用的官网模板编码';
-- 6. 初始登记内置默认模板(可选,platform 端扫描目录也会自动登记)
INSERT INTO `yz_cms_frontend_template` (`code`, `name`, `description`, `preview`, `status`, `sort`, `create_time`)
SELECT 'default', '默认模板', '系统内置默认官网模板', '/themes/default/static/preview.png', 1, 0, NOW()
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `yz_cms_frontend_template` WHERE `code` = 'default' AND `delete_time` IS NULL
);
-35
View File
@@ -1,35 +0,0 @@
-- CMS 产品与产品分类表(对应 backend 租户端 apps/cms/product 模块)
-- 接口:/backend/productsList、/backend/addProducts、/backend/editProducts/:id、/backend/deleteProducts/:id
-- /backend/productsTypesList、/backend/addProductsTypes、/backend/editProductsTypes/:id、/backend/deleteProductsTypes/:id
CREATE TABLE IF NOT EXISTS yz_cms_product (
id bigint unsigned NOT NULL AUTO_INCREMENT,
tid bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
title varchar(100) NOT NULL DEFAULT '' COMMENT '产品名称',
thumb varchar(500) NOT NULL DEFAULT '' COMMENT '产品图片',
`desc` varchar(500) NOT NULL DEFAULT '' COMMENT '产品描述',
content mediumtext COMMENT '产品内容(富文本)',
url varchar(500) NOT NULL DEFAULT '' COMMENT '跳转地址',
sort int NOT NULL DEFAULT 0 COMMENT '排序',
status tinyint NOT NULL DEFAULT 1 COMMENT '状态 1启用 0禁用',
create_time datetime NOT NULL,
update_time datetime DEFAULT NULL,
delete_time datetime DEFAULT NULL COMMENT '软删除时间',
PRIMARY KEY (id),
KEY idx_tid_status (tid, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CMS产品';
CREATE TABLE IF NOT EXISTS yz_cms_product_category (
id bigint unsigned NOT NULL AUTO_INCREMENT,
tid bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
title varchar(100) NOT NULL DEFAULT '' COMMENT '分类名称',
pid bigint unsigned NOT NULL DEFAULT 0 COMMENT '父级分类ID,0为顶级',
`desc` varchar(500) NOT NULL DEFAULT '' COMMENT '分类描述',
sort int NOT NULL DEFAULT 0 COMMENT '排序',
status tinyint NOT NULL DEFAULT 1 COMMENT '状态 1启用 0禁用',
create_time datetime NOT NULL,
update_time datetime DEFAULT NULL,
delete_time datetime DEFAULT NULL COMMENT '软删除时间',
PRIMARY KEY (id),
KEY idx_tid_pid (tid, pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CMS产品分类';
-34
View File
@@ -1,34 +0,0 @@
-- CMS 解决方案(特色服务)与分类表(对应 backend 租户端 apps/cms/solution 模块)
-- 接口:/backend/servicesList、/backend/addServices、/backend/editServices/:id、/backend/deleteServices/:id
-- /backend/servicesTypesList、/backend/addServicesTypes、/backend/editServicesTypes/:id、/backend/deleteServicesTypes/:id
CREATE TABLE IF NOT EXISTS yz_cms_solution (
id bigint unsigned NOT NULL AUTO_INCREMENT,
tid bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
title varchar(100) NOT NULL DEFAULT '' COMMENT '名称',
thumb varchar(500) NOT NULL DEFAULT '' COMMENT '图片',
`desc` varchar(500) NOT NULL DEFAULT '' COMMENT '描述',
url varchar(500) NOT NULL DEFAULT '' COMMENT '跳转地址',
sort int NOT NULL DEFAULT 0 COMMENT '排序',
status tinyint NOT NULL DEFAULT 1 COMMENT '状态 1启用 0禁用',
create_time datetime NOT NULL,
update_time datetime DEFAULT NULL,
delete_time datetime DEFAULT NULL COMMENT '软删除时间',
PRIMARY KEY (id),
KEY idx_tid_status (tid, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CMS解决方案(特色服务)';
CREATE TABLE IF NOT EXISTS yz_cms_solution_category (
id bigint unsigned NOT NULL AUTO_INCREMENT,
tid bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
title varchar(100) NOT NULL DEFAULT '' COMMENT '分类名称',
pid bigint unsigned NOT NULL DEFAULT 0 COMMENT '父级分类ID,0为顶级',
`desc` varchar(500) NOT NULL DEFAULT '' COMMENT '分类描述',
sort int NOT NULL DEFAULT 0 COMMENT '排序',
status tinyint NOT NULL DEFAULT 1 COMMENT '状态 1启用 0禁用',
create_time datetime NOT NULL,
update_time datetime DEFAULT NULL,
delete_time datetime DEFAULT NULL COMMENT '软删除时间',
PRIMARY KEY (id),
KEY idx_tid_pid (tid, pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CMS解决方案分类';
-37
View File
@@ -1,37 +0,0 @@
-- 平台端和租户后台密码存储表。支持一个平台下挂载多个账号(每个账号包含账号、密码、注册信息、备注)。
CREATE TABLE IF NOT EXISTS `yz_platform_password_store` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`platform` varchar(100) NOT NULL DEFAULT '' COMMENT '平台名称',
`url` varchar(500) NOT NULL DEFAULT '' COMMENT '平台网址/登录地址',
`accounts` longtext COMMENT '账号列表JSON: [{"username":"","password":"","registration_info":"","remark":""}]',
`remark` text COMMENT '平台备注',
`user_id` bigint unsigned NOT NULL COMMENT '所属用户ID',
`is_deleted` tinyint NOT NULL DEFAULT 0,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL,
`delete_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_platform_user` (`platform`,`user_id`),
KEY `idx_user_deleted` (`user_id`,`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台密码存储';
CREATE TABLE IF NOT EXISTS `yz_backend_password_store` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`tid` bigint NOT NULL COMMENT '租户ID',
`platform` varchar(100) NOT NULL DEFAULT '' COMMENT '平台名称',
`url` varchar(500) NOT NULL DEFAULT '' COMMENT '平台网址/登录地址',
`accounts` longtext COMMENT '账号列表JSON: [{"username":"","password":"","registration_info":"","remark":""}]',
`remark` text COMMENT '平台备注',
`user_id` bigint unsigned NOT NULL COMMENT '所属用户ID',
`is_deleted` tinyint NOT NULL DEFAULT 0,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL,
`delete_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_tid_user` (`tid`,`user_id`),
KEY `idx_tid_platform` (`tid`,`platform`),
KEY `idx_tid_deleted` (`tid`,`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户密码存储';
-- 已经按旧的「单账号」结构建过表的库,请执行
-- sql/alter_password_store_to_multi_accounts.sql 完成升级与数据迁移。
@@ -1,28 +0,0 @@
-- 设备 IP 登录日志表
-- 每次客户端调用 report / activateByCode 接口时,若携带 ipInfo,则向本表插入一条记录
CREATE TABLE IF NOT EXISTS `yz_platform_cursor_equipment_ip_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`equipment_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联 yz_platform_cursor_equipment.id,0 表示设备尚未创建时的记录',
`machine_code` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '机器码(冗余,便于无 equipment_id 时查询)',
`source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源:report / activateByCode',
-- ip-api.com 返回的原始字段
`status` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'ip-api status',
`country` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '国家',
`country_code` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '国家代码',
`region` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '省/州代码',
`region_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '省/州名称',
`city` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '城市',
`zip` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '邮编',
`lat` DOUBLE NOT NULL DEFAULT 0 COMMENT '纬度',
`lon` DOUBLE NOT NULL DEFAULT 0 COMMENT '经度',
`timezone` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '时区',
`isp` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ISP 运营商',
`org` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '组织',
`as_info` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AS 信息',
`query` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '出口 IP 地址',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间',
PRIMARY KEY (`id`),
KEY `idx_equipment_id` (`equipment_id`),
KEY `idx_machine_code` (`machine_code`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备 IP 登录日志';
-18
View File
@@ -1,18 +0,0 @@
-- 平台记事本表
CREATE TABLE IF NOT EXISTS `yz_platform_notebook` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '笔记标题',
`content` longtext COMMENT '笔记内容(HTML格式)',
`pinned` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否置顶 0-否 1-是',
`user_id` bigint(20) unsigned DEFAULT NULL COMMENT '创建用户ID',
`user_name` varchar(100) DEFAULT NULL COMMENT '创建用户名',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除 0-否 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_pinned` (`pinned`),
KEY `idx_create_time` (`create_time`),
KEY `idx_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台记事本表';
-20
View File
@@ -1,20 +0,0 @@
-- 登录日志表
CREATE TABLE IF NOT EXISTS `yz_system_login_log` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`tid` bigint(20) unsigned DEFAULT NULL COMMENT '租户ID',
`user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '用户ID',
`account` varchar(64) NOT NULL DEFAULT '' COMMENT '登录账号',
`user_name` varchar(64) NOT NULL DEFAULT '' COMMENT '用户姓名',
`tenant_name` varchar(64) NOT NULL DEFAULT '' COMMENT '租户名称',
`login_type` varchar(20) NOT NULL DEFAULT 'password' COMMENT '登录方式: password-密码, sms-短信验证码, email-邮箱验证码',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '登录状态: 0-失败, 1-成功',
`message` varchar(255) NOT NULL DEFAULT '' COMMENT '结果信息/失败原因',
`ip` varchar(50) NOT NULL DEFAULT '' COMMENT '登录IP',
`user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT '浏览器User-Agent',
`create_time` datetime DEFAULT NULL COMMENT '登录时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
KEY `idx_tid` (`tid`),
KEY `idx_account` (`account`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统登录日志表';
-36
View File
@@ -1,36 +0,0 @@
-- 站内信配置表
CREATE TABLE IF NOT EXISTS `yz_system_sitereminder` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`retention_days` int(11) NOT NULL DEFAULT '30' COMMENT '消息保留天数',
`auto_read` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否自动标记已读: 0-否, 1-是',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内信配置表';
-- 默认插入一条配置数据
INSERT INTO `yz_system_sitereminder` (`id`, `retention_days`, `auto_read`, `create_time`, `update_time`)
VALUES (1, 30, 0, NOW(), NOW())
ON DUPLICATE KEY UPDATE `update_time` = NOW();
-- 站内信消息列表表
CREATE TABLE IF NOT EXISTS `yz_system_reminderlist` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`title` varchar(255) NOT NULL COMMENT '标题',
`content` text NOT NULL COMMENT '内容',
`sender_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '发送者ID (0为系统)',
`sender_type` varchar(32) NOT NULL DEFAULT 'system' COMMENT '发送者类型: system, platform, tenant',
`receiver_id` bigint(20) unsigned NOT NULL COMMENT '接收者ID',
`receiver_type` varchar(32) NOT NULL DEFAULT 'receiver' COMMENT '接收者类型: platform, tenant',
`is_read` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否已读: 0-未读, 1-已读',
`read_time` datetime DEFAULT NULL COMMENT '已读时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间/发送时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
`batch_id` varchar(64) NOT NULL DEFAULT '' COMMENT '批次号/分组ID',
`target_type` varchar(32) NOT NULL DEFAULT '' COMMENT '发送目标类型: platform, tenant_all, role, tenant',
`target_role_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标角色ID',
`target_tenant_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标租户ID',
PRIMARY KEY (`id`),
KEY `idx_receiver` (`receiver_type`, `receiver_id`, `is_read`),
KEY `idx_batch` (`batch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内信消息列表表';