优化合同个管理和产品管理相关功能
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '生成该消息使用的模型';
|
||||
@@ -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');
|
||||
@@ -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产品分类表';
|
||||
@@ -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);
|
||||
@@ -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续杯激活码';
|
||||
@@ -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`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"` // 来源项目名称
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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='通讯录表';
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 通讯录表全局化:从 ERP 模块独立为全局基础功能,表名去掉 erp 前缀
|
||||
-- 对应模型:go/models/contact.go(BackendErpContact)
|
||||
-- RENAME 会保留全部数据与索引,对存量环境执行本脚本即可完成迁移
|
||||
|
||||
RENAME TABLE `yz_backend_erp_contact` TO `yz_backend_contact`;
|
||||
@@ -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`;
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`;
|
||||
@@ -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`);
|
||||
@@ -1,7 +0,0 @@
|
||||
-- 清理旧版本自动插入的文章分类(旧代码 cmsEnsureTables 每次请求都会调
|
||||
-- EnsureCmsArticleDefaultCategories,向 yz_cms_article_category 插入
|
||||
-- tid=0 的「文章中心」「新闻中心」,导致刷新页面分类不断增多)。
|
||||
-- 新代码已移除该逻辑,分类完全由后台手动维护。
|
||||
-- 执行前请先备份;如需保留其中一条,请自行注释对应语句。
|
||||
|
||||
DELETE FROM yz_cms_article_category WHERE tid = 0 AND `name` IN ('文章中心', '新闻中心');
|
||||
@@ -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='管理端通用配置表';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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合同表';
|
||||
@@ -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;
|
||||
|
||||
@@ -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='员工档案子记录表';
|
||||
@@ -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通知公告表';
|
||||
@@ -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日程管理表(日历待办)';
|
||||
@@ -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);
|
||||
@@ -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='审批记录表';
|
||||
@@ -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
|
||||
);
|
||||
@@ -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产品分类';
|
||||
@@ -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解决方案分类';
|
||||
@@ -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 登录日志';
|
||||
@@ -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='平台记事本表';
|
||||
@@ -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='系统登录日志表';
|
||||
@@ -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='站内信消息列表表';
|
||||
Reference in New Issue
Block a user