新增合同管理相关功能

This commit is contained in:
2026-09-11 18:22:22 +08:00
parent 840673bda5
commit 184c604bee
17 changed files with 2944 additions and 0 deletions
+2
View File
@@ -21,12 +21,14 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+46
View File
@@ -0,0 +1,46 @@
import request from "@/utils/request";
/**
* CRM 合同管理接口
*
* 说明:
* - 合同绑定项目:project_id 为空即为「无头合同」,否则为「项目合同」;
* - 合同状态 status:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常;
* - 参与方 parties / 产品清单 products / 金额汇总 summary 随主表一并以 JSON 提交,
* 后端按需落库;step 记录进度式创建所到达的步骤,支持每步保存续填。
*/
/** 合同列表 */
export function getContractList(params) {
return request({ url: "/backend/crm/contract/list", method: "get", params });
}
/** 合同详情 */
export function getContractDetail(id) {
return request({ url: `/backend/crm/contract/${id}`, method: "get" });
}
/** 创建合同(首次保存草稿,返回 id 后续转为更新) */
export function createContract(data) {
return request({ url: "/backend/crm/contract", method: "post", data });
}
/** 更新合同(进度式保存每一步都走这里) */
export function updateContract(id, data) {
return request({ url: `/backend/crm/contract/${id}`, method: "put", data });
}
/** 删除合同 */
export function deleteContract(id) {
return request({ url: `/backend/crm/contract/${id}`, method: "delete" });
}
/** 合同状态流转:status 1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常 */
export function changeContractStatus(id, status) {
return request({ url: `/backend/crm/contract/${id}/status`, method: "post", data: { status } });
}
/** 合同统计(列表页顶部卡片) */
export function getContractStats(params) {
return request({ url: "/backend/crm/contract/stats", method: "get", params });
}
@@ -0,0 +1,5 @@
<template>
<div />
</template>
<script setup></script>
@@ -0,0 +1,236 @@
<template>
<div class="party-select" :class="{ own: locked }">
<div class="party-head">
<span class="party-role">{{ roleLabel }}</span>
<el-tag v-if="locked" size="small" type="primary" effect="light">本公司</el-tag>
<el-tag v-else-if="party?.ref_name" size="small" type="info" effect="plain">
{{ party.ref_type === 2 ? "供应商" : "客户" }}
</el-tag>
<span v-if="locked" class="party-company">{{ tenantName }}</span>
</div>
<!-- 关联主体:当前租户方锁定,其余模糊搜索客户库 / 供应商库 -->
<template v-if="!locked">
<div class="party-row">
<el-select
v-if="allowChooseRefType"
v-model="party.ref_type"
class="ref-type-select"
size="default"
@change="handleRefTypeChange"
>
<el-option label="客户" :value="1" />
<el-option label="供应商" :value="2" />
</el-select>
<el-select
v-model="party.ref_id"
class="ref-select"
filterable
remote
clearable
reserve-keyword
:loading="searching"
:remote-method="handleSearch"
:placeholder="`搜索${refTypeText}名称`"
@change="handlePick"
@clear="handleClear"
@visible-change="handleDropdownOpen"
>
<el-option
v-for="item in options"
:key="item.id"
:label="item.label"
:value="item.id"
>
<span class="option-name">{{ item.label }}</span>
<span class="option-sub">{{ item.sub }}</span>
</el-option>
<template #footer>
<div class="select-footer">
<el-button link type="primary" :icon="Plus" @click="emit('create')">
新建{{ refTypeText }}
</el-button>
</div>
</template>
</el-select>
<el-tooltip :content="`没有找到?点击新建${refTypeText}`" placement="top">
<el-button :icon="Plus" @click="emit('create')" />
</el-tooltip>
</div>
<div v-if="party.ref_name" class="picked-name">已关联:{{ party.ref_name }}</div>
</template>
<!-- 签约人 -->
<div class="party-row signer-row">
<el-input
v-model="party.signer_name"
placeholder="签约人姓名"
maxlength="30"
/>
<el-input v-model="party.signer_phone" placeholder="联系电话" maxlength="20" />
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
import { useAuthStore } from "@/stores/auth";
const props = defineProps({
/** 参与方对象:{ role, ref_type, ref_id, ref_name, signer_name, signer_phone } */
modelValue: { type: Object, required: true },
/** 甲方 / 乙方 / 丙方 / 丁方 */
roleLabel: { type: String, default: "" },
/** 是否为当前租户方(锁定为本公司) */
locked: { type: Boolean, default: false },
/** 丙/丁方允许在客户 / 供应商库之间切换来源 */
allowChooseRefType: { type: Boolean, default: false },
});
const emit = defineEmits(["create"]);
const authStore = useAuthStore();
const tenantName = computed(() => authStore.user?.tenant_name || "本公司");
/** 参与方对象由父组件持有,这里直接读写其字段 */
const party = computed(() => props.modelValue);
const searching = ref(false);
const options = ref([]);
const refTypeText = computed(() =>
Number(party.value.ref_type) === 2 ? "供应商" : "客户"
);
/** 初次打开按已有名称兜底展示,避免回显成 id */
const ensureOption = () => {
if (party.value?.ref_id && party.value?.ref_name) {
const exists = options.value.some((i) => String(i.id) === String(party.value.ref_id));
if (!exists) {
options.value = [
{ id: party.value.ref_id, label: party.value.ref_name, sub: "" },
...options.value,
];
}
}
};
watch(
() => party.value?.ref_id,
() => ensureOption(),
{ immediate: true }
);
const remoteSearch = async (keyword) => {
searching.value = true;
try {
const api =
Number(party.value.ref_type) === 2 ? getCrmSupplierList : getCrmCustomerList;
const res = await api({ keyword, page: 1, pageSize: 50 });
const list = res?.data?.list || [];
options.value = list.map((item) => ({
id: item.id,
label: item.customer_name || item.supplier_name || item.name || `#${item.id}`,
sub: item.contact_person || item.industry || "",
}));
ensureOption();
} catch {
options.value = [];
} finally {
searching.value = false;
}
};
const handleSearch = (keyword) => remoteSearch(keyword || "");
/** 下拉首次展开时加载全量候选 */
const handleDropdownOpen = (visible) => {
if (visible && options.value.length === 0) remoteSearch("");
};
const handlePick = (id) => {
const hit = options.value.find((i) => String(i.id) === String(id));
party.value.ref_name = hit?.label || "";
};
const handleClear = () => {
party.value.ref_id = null;
party.value.ref_name = "";
options.value = [];
};
/** 丙/丁方切换客户 / 供应商来源时清空已选 */
const handleRefTypeChange = () => {
party.value.ref_id = null;
party.value.ref_name = "";
options.value = [];
};
</script>
<style lang="less" scoped>
.party-select {
padding: 14px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
transition: border-color 0.2s, background 0.2s;
/* 本公司方:主色浅底突出显示 */
&.own {
border-color: var(--el-color-primary-light-5);
background: var(--el-color-primary-light-9);
}
.party-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.party-role {
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.party-company {
margin-left: auto;
font-size: 13px;
color: var(--el-color-primary);
font-weight: 500;
}
}
.party-row {
display: flex;
gap: 8px;
&.signer-row {
margin-top: 8px;
}
.ref-type-select {
width: 96px;
flex-shrink: 0;
}
.ref-select {
flex: 1;
}
}
.picked-name {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.select-footer {
text-align: center;
}
}
</style>
@@ -0,0 +1,238 @@
<template>
<div class="product-list">
<el-table :data="products" border row-key="__key" :empty-text="'暂无产品,点击下方按钮添加'">
<el-table-column label="序号" type="index" width="50" align="center" />
<el-table-column label="产品名称" min-width="160">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="请输入产品/服务名称" maxlength="100" />
</template>
</el-table-column>
<el-table-column label="产品类别" width="140">
<template #default="{ row }">
<el-select v-model="row.category" placeholder="类别">
<el-option
v-for="i in CONTRACT_PRODUCT_CATEGORY_OPTIONS"
:key="i.value"
:label="i.label"
:value="i.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="规格型号" width="130">
<template #default="{ row }">
<el-input v-model="row.spec" :placeholder="specPlaceholder(row)" />
</template>
</el-table-column>
<el-table-column label="单位" width="80">
<template #default="{ row }">
<el-input v-model="row.unit" placeholder="套" maxlength="10" />
</template>
</el-table-column>
<el-table-column label="数量" width="110">
<template #default="{ row }">
<el-input-number
v-model="row.quantity"
:min="0"
:precision="2"
:controls="false"
placeholder="0"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="单价(元)" width="130">
<template #default="{ row }">
<el-input-number
v-model="row.price"
:min="0"
:precision="2"
:controls="false"
placeholder="0.00"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="成本单价(元)" width="140">
<template #default="{ row }">
<el-input-number
v-model="row.cost_price"
:min="0"
:precision="2"
:controls="false"
placeholder="0.00"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="小计(元)" width="120" align="right">
<template #default="{ row }">
<span class="line-amount">{{ formatMoney(rowAmount(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" maxlength="100" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeRow($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="add-row">
<el-button :icon="Plus" @click="addRow">添加产品</el-button>
<span class="add-tip">按类别自动归集:硬件 / 软件单列金额,服务、开发等计入其他</span>
</div>
<!-- 金额汇总 -->
<div class="summary-panel">
<div class="summary-grid">
<div class="summary-item hardware">
<span class="summary-label">硬件部分金额</span>
<span class="summary-value">¥{{ formatMoney(summary.hardware_amount) }}</span>
</div>
<div class="summary-item software">
<span class="summary-label">软件部分金额</span>
<span class="summary-value">¥{{ formatMoney(summary.software_amount) }}</span>
</div>
<div class="summary-item other">
<span class="summary-label">其他部分金额</span>
<span class="summary-value">¥{{ formatMoney(summary.other_amount) }}</span>
</div>
<div class="summary-item total">
<span class="summary-label">合同总金额</span>
<span class="summary-value">¥{{ formatMoney(summary.total_amount) }}</span>
</div>
<div class="summary-item cost">
<span class="summary-label">产品总成本</span>
<span class="summary-value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<div class="summary-item profit">
<span class="summary-label">合同总利润</span>
<span class="summary-value" :class="{ loss: summary.total_profit < 0 }">
¥{{ formatMoney(summary.total_profit) }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { buildSummary } from "./utils";
const props = defineProps({
/** 产品行数组(父组件持有) */
products: { type: Array, required: true },
});
const summary = computed(() => buildSummary(props.products));
let rowSeed = 0;
const addRow = () => {
props.products.push({
__key: `row_${Date.now()}_${rowSeed++}`,
name: "",
category: "",
spec: "",
unit: "",
quantity: 1,
price: 0,
cost_price: 0,
remark: "",
});
};
const removeRow = (index) => {
props.products.splice(index, 1);
};
const rowAmount = (row) =>
Math.round(((Number(row.quantity) || 0) * (Number(row.price) || 0) || 0) * 100) / 100;
const specPlaceholder = (row) => {
if (String(row.category) === "1") return "如:型号 / 配置";
if (String(row.category) === "4") return "如:功能模块 / 里程碑";
return "规格 / 说明";
};
</script>
<style lang="less" scoped>
.product-list {
.line-amount {
font-weight: 600;
color: var(--el-text-color-primary);
}
.add-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
.add-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.summary-panel {
margin-top: 16px;
padding: 16px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.summary-item {
padding: 12px 16px;
border-radius: 6px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
display: flex;
flex-direction: column;
gap: 6px;
.summary-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.summary-value {
font-size: 18px;
font-weight: 700;
color: var(--el-text-color-primary);
&.loss {
color: var(--el-color-danger);
}
}
&.hardware .summary-value {
color: var(--el-color-warning);
}
&.software .summary-value {
color: var(--el-color-primary);
}
&.total .summary-value {
color: var(--el-color-primary);
font-size: 20px;
}
&.profit .summary-value {
color: var(--el-color-success);
}
}
}
</style>
@@ -0,0 +1,143 @@
<template>
<el-dialog
:model-value="visible"
:title="`快速新建${refTypeText}`"
width="520px"
:close-on-click-modal="false"
@update:model-value="handleClose"
@closed="handleClosed"
>
<el-alert
:title="`创建成功后将自动关联到合同参与方`"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item :label="`${refTypeText}名称`" prop="name">
<el-input v-model="form.name" :placeholder="`请输入${refTypeText}名称`" />
</el-form-item>
<el-form-item :label="`${refTypeText}类型`" prop="type">
<el-select v-model="form.type" style="width: 100%">
<el-option
v-for="i in typeOptions"
:key="i.value"
:label="i.label"
:value="i.value"
/>
</el-select>
</el-form-item>
<el-form-item label="联系人" prop="contact_person">
<el-input v-model="form.contact_person" placeholder="请输入联系人(选填)" />
</el-form-item>
<el-form-item label="联系电话" prop="contact_phone">
<el-input v-model="form.contact_phone" placeholder="请输入联系电话(选填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
创建并关联
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { createCrmCustomer, createCrmSupplier } from "@/api/crm";
import { CUSTOMER_TYPE_OPTIONS, SUPPLIER_TYPE_OPTIONS } from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
/** 1=客户 2=供应商 */
refType: { type: [Number, String], default: 1 },
/** 从搜索词带入名称 */
presetName: { type: String, default: "" },
});
const emit = defineEmits(["update:visible", "created"]);
const formRef = ref();
const submitting = ref(false);
const refTypeText = computed(() => (Number(props.refType) === 2 ? "供应商" : "客户"));
const typeOptions = computed(() =>
Number(props.refType) === 2 ? SUPPLIER_TYPE_OPTIONS : CUSTOMER_TYPE_OPTIONS
);
const defaultForm = () => ({
name: "",
type: "1",
contact_person: "",
contact_phone: "",
});
const form = reactive(defaultForm());
const rules = {
name: [{ required: true, message: "名称不能为空", trigger: "blur" }],
};
watch(
() => props.visible,
(val) => {
if (val) {
Object.assign(form, defaultForm(), { name: props.presetName || "" });
}
}
);
function handleClose() {
emit("update:visible", false);
}
function handleClosed() {
formRef.value?.resetFields();
Object.assign(form, defaultForm());
}
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
submitting.value = true;
try {
const payload = {
status: "1",
remark: "",
contact_person: form.contact_person,
contact_phone: form.contact_phone,
};
let row;
if (Number(props.refType) === 2) {
payload.supplier_name = form.name;
payload.supplier_type = form.type;
const res = await createCrmSupplier(payload);
row = res?.data || { id: null, supplier_name: form.name };
} else {
payload.customer_name = form.name;
payload.customer_type = form.type;
const res = await createCrmCustomer(payload);
row = res?.data || { id: null, customer_name: form.name };
}
ElMessage.success(`${refTypeText.value}创建成功`);
emit("created", {
...row,
id: row?.id ?? row?.customer_id ?? row?.supplier_id ?? null,
label: form.name,
});
handleClose();
} catch (e) {
ElMessage.error(e.message || "创建失败");
} finally {
submitting.value = false;
}
}
</script>
@@ -0,0 +1,723 @@
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="1080px"
top="4vh"
:close-on-click-modal="false"
@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>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px" style="margin-top: 20px">
<!-- 步骤 1:合同信息 -->
<div v-show="activeStep === 0">
<!-- 合同参与方:先定形式与我方角色,再逐方选择签约对象 -->
<el-divider content-position="left">合同参与方</el-divider>
<div class="party-config">
<div class="config-block">
<span class="config-label">合同形式</span>
<div class="form-cards">
<div
v-for="i in CONTRACT_PARTY_COUNT_OPTIONS"
:key="i.value"
class="form-card"
:class="{ active: Number(form.party_count) === Number(i.value) }"
@click="setPartyCount(i.value)"
>
<span class="form-card-name">{{ partyCountName(i.value) }}</span>
<!-- <s pan class="form-card-roles">{{ partyCountRoles(i.value) }}</span> -->
</div>
</div>
</div>
<div class="config-block">
<span class="config-label">我方角色</span>
<el-radio-group v-model="form.our_role">
<el-radio-button v-for="i in roleOptionsForCount" :key="i.value" :value="i.value">
{{ i.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="parties-grid">
<div v-for="role in activeRoles" :key="role.key">
<PartySelect
:model-value="getParty(role.key)"
:role-label="role.label"
:locked="isOwnParty(role.key)"
:allow-choose-ref-type="!isOwnParty(role.key)"
@create="openQuickCreate(role.key)"
/>
</div>
</div>
<div class="nature-tip">
<el-icon><InfoFilled /></el-icon>
<span>
我方({{ tenantName }})已自动填入{{ ownPartyRoleLabel }};其余各方从客户库或供应商库中搜索关联,也可点击 + 快速新建。
</span>
</div>
<!-- 项目关联 -->
<el-divider content-position="left">
项目关联
<el-tag v-if="form.project_id" size="small" type="primary" effect="light" style="margin-left: 8px">
项目合同
</el-tag>
<el-tag v-else size="small" type="warning" effect="light" style="margin-left: 8px">
无头合同
</el-tag>
</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="关联项目" prop="project_id">
<el-select
v-model="form.project_id"
filterable
remote
clearable
reserve-keyword
:loading="projectSearching"
:remote-method="searchProjects"
placeholder="搜索项目名称,留空则为无头合同"
style="width: 100%"
@change="handleProjectChange"
@visible-change="handleProjectDropdown"
>
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.project_name"
:value="item.id"
>
<span class="option-name">{{ item.project_name }}</span>
<span class="option-sub">{{ item.project_no }}{{ item.customer_name ? ` / ${item.customer_name}` : "" }}</span>
</el-option>
</el-select>
</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>
</el-col>
</el-row>
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-row :gutter="20">
<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_no">
<el-input v-model="form.contract_no" placeholder="保存时自动生成" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同分类" prop="contract_category">
<el-select v-model="form.contract_category" placeholder="请选择" style="width: 100%">
<el-option
v-for="i in CONTRACT_CATEGORY_OPTIONS"
:key="i.value"
:label="i.label"
:value="i.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="签订日期" required>
<el-row :gutter="12" style="width: 100%">
<el-col :span="8">
<el-form-item prop="sign_date" style="margin-bottom: 0">
<el-date-picker
v-model="form.sign_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="签订日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item prop="effective_date" style="margin-bottom: 0">
<el-date-picker
v-model="form.effective_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item prop="expire_date" style="margin-bottom: 0">
<el-date-picker
v-model="form.expire_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="结束日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark" style="margin-top: 16px">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="合同补充说明(选填)" />
</el-form-item>
</div>
<!-- 步骤 2:产品清单 -->
<div v-show="activeStep === 1">
<ProductList :products="products" />
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<div class="footer-left">
<el-button v-if="activeStep > 0" @click="prevStep">上一步</el-button>
</div>
<div class="footer-right">
<el-button @click="handleClose">取消</el-button>
<el-button :loading="saving" @click="handleSaveProgress">
保存{{ isLastStep ? "" : ",稍后继续" }}
</el-button>
<el-button v-if="!isLastStep" type="primary" :loading="saving" @click="nextStep">
下一步
</el-button>
<el-button v-else type="primary" :loading="saving" @click="handleFinish">
保存并关闭
</el-button>
</div>
</div>
</template>
<!-- 快速新建客户 / 供应商 -->
<QuickCreateParty
v-model:visible="quickVisible"
:ref-type="quickRefType"
:preset-name="quickPresetName"
@created="handleQuickCreated"
/>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { InfoFilled } from "@element-plus/icons-vue";
import { createContract, updateContract } from "@/api/crmContract";
import { getProjectList } from "@/api/crmPipeline";
import { useAuthStore } from "@/stores/auth";
import {
CONTRACT_CATEGORY_OPTIONS,
OUR_ROLE_OPTIONS,
CONTRACT_PARTY_COUNT_OPTIONS,
} from "../../dict";
import PartySelect from "./PartySelect.vue";
import QuickCreateParty from "./QuickCreateParty.vue";
import ProductList from "./ProductList.vue";
import { buildSummary, genContractNo, PARTY_ROLES } from "./utils";
const MAX_STEP = 1;
const props = defineProps({
visible: { type: Boolean, default: false },
/** 编辑草稿 / 查看合同 */
editData: { type: Object, default: null },
/** 打开时定位的步骤(1=合同信息 2=产品清单) */
initStep: { type: Number, default: 1 },
});
const emit = defineEmits(["update:visible", "success"]);
const authStore = useAuthStore();
const formRef = ref();
const activeStep = ref(0);
const saving = ref(false);
const isEdit = ref(false);
const internalId = ref(null);
const dialogTitle = computed(() => {
if (!isEdit.value) return "新建合同";
return Number(form.status) === 1 ? "编辑合同(草稿)" : "编辑合同";
});
const isLastStep = computed(() => activeStep.value >= MAX_STEP);
/* ------------------------------ 表单数据 ------------------------------ */
const emptyForm = () => ({
contract_no: "",
contract_name: "",
contract_category: "",
our_role: 2, // 我方角色:1甲方 2乙方 3丙方 4丁方,默认乙方
party_count: 2, // 合同形式:2双方 3三方 4四方,默认双方
project_id: null,
project_name: "",
owner_name: authStore.user?.name || "",
sign_date: "",
effective_date: "",
expire_date: "",
remark: "",
status: 1,
step: 1,
});
const form = reactive(emptyForm());
/** 参与方(甲乙丙丁):ref_type 0=本公司 1=客户 2=供应商,非我方默认客户可切换 */
const emptyParty = (role) => ({
role,
ref_type: 1,
ref_id: null,
ref_name: "",
signer_name: "",
signer_phone: "",
});
const parties = ref([]);
const products = ref([]);
/** 初始化各方:当前租户锁定占据我方角色一方,其余为待选参与方 */
const initParties = () => {
const count = Number(form.party_count) || 2;
parties.value = PARTY_ROLES.slice(0, count).map((r) => emptyParty(r.key));
};
/** 当前租户在合同中的角色:由「我方角色」决定(1甲方/2乙方/3丙方/4丁方) */
const ownPartyRole = computed(() => {
const idx = Math.min(Math.max(Number(form.our_role) || 2, 1), 4);
return PARTY_ROLES[idx - 1].key;
});
const ownPartyRoleLabel = computed(() => {
const hit = PARTY_ROLES.find((r) => r.key === ownPartyRole.value);
return hit?.label || "乙方";
});
const tenantName = computed(() => authStore.user?.tenant_name || "本公司");
const isOwnParty = (role) => role === ownPartyRole.value;
const activeRoles = computed(() => PARTY_ROLES.slice(0, Number(form.party_count) || 2));
const getParty = (role) => {
let hit = parties.value.find((p) => p.role === role);
if (!hit) {
hit = emptyParty(role);
parties.value.push(hit);
}
return hit;
};
/* ------------------------------ 校验规则 ------------------------------ */
const rules = {
contract_name: [{ required: true, message: "请输入合同名称", trigger: "blur" }],
contract_category: [{ required: true, message: "请选择合同分类", trigger: "change" }],
sign_date: [{ required: true, message: "请选择签订日期", trigger: "change" }],
};
/* ------------------------------ 项目搜索 ------------------------------ */
const projectSearching = ref(false);
const projectOptions = ref([]);
const searchProjects = async (keyword) => {
projectSearching.value = true;
try {
const res = await getProjectList({ keyword: keyword || "", page: 1, pageSize: 50 });
projectOptions.value = res?.data?.list || [];
} catch {
projectOptions.value = [];
} finally {
projectSearching.value = false;
}
};
const handleProjectDropdown = (visible) => {
if (visible && projectOptions.value.length === 0) searchProjects("");
};
const handleProjectChange = (id) => {
const hit = projectOptions.value.find((i) => String(i.id) === String(id));
form.project_name = hit?.project_name || "";
};
/* ------------------------------ 联动 ------------------------------ */
/** 我方角色切换:原我方还原为待选方(保留签约人),新我方写入当前租户,其余各方不受影响 */
watch(
() => form.our_role,
(nv, ov) => {
if (!props.visible) return;
const roleKeyOf = (v) => PARTY_ROLES[Math.min(Math.max(Number(v) || 2, 1), 4) - 1].key;
const prevKey = roleKeyOf(ov);
const nextKey = roleKeyOf(nv);
if (prevKey === nextKey) return;
const prev = parties.value.find((p) => p.role === prevKey);
if (prev) {
const signerName = prev.signer_name;
const signerPhone = prev.signer_phone;
Object.assign(prev, emptyParty(prev.role), { signer_name: signerName, signer_phone: signerPhone });
}
applyOwnParty();
}
);
/** 合同形式文案:2双方 3三方 4四方 */
const partyCountName = (v) => ({ 2: "双方", 3: "三方", 4: "四方" }[Number(v)] || "双方");
/** 形式对应的参与方示意:甲方 / 乙方(/ 丙方 / 丁方) */
const partyCountRoles = (v) =>
PARTY_ROLES.slice(0, Number(v) || 2)
.map((r) => r.label)
.join(" / ");
/** 我方角色候选随合同形式收敛:双方只能选甲乙,三方甲乙丙,四方全部 */
const roleOptionsForCount = computed(() =>
OUR_ROLE_OPTIONS.slice(0, Number(form.party_count) || 2)
);
/** 切换合同形式:增删丙丁方保留已有内容;我方角色超出范围时收敛到乙方 */
const setPartyCount = (v) => {
form.party_count = Number(v) || 2;
const max = Number(form.party_count);
if (Number(form.our_role) > max) {
form.our_role = 2;
}
parties.value = PARTY_ROLES.slice(0, max).map(
(r) => parties.value.find((p) => p.role === r.key) || emptyParty(r.key)
);
applyOwnParty();
};
/** 把当前租户写入本公司方 */
const applyOwnParty = () => {
const own = parties.value.find((p) => p.role === ownPartyRole.value);
if (own) {
own.ref_type = 0;
own.ref_id = 0;
own.ref_name = tenantName.value;
}
};
watch(
() => props.visible,
(val) => {
if (!val) return;
activeStep.value = Math.max(0, Math.min(MAX_STEP, (props.initStep || 1) - 1));
internalId.value = null;
products.value = [];
if (props.editData?.id) {
isEdit.value = true;
internalId.value = props.editData.id;
Object.assign(form, emptyForm(), props.editData);
// 类型归一:后端返回数字,统一转为对应类型确保回显匹配
form.our_role = Number(props.editData.our_role) || 2;
form.contract_category =
props.editData.contract_category == null ? "" : String(props.editData.contract_category);
form.party_count = Number(props.editData.party_count) || 2;
form.status = Number(props.editData.status) || 1;
form.step = Number(props.editData.step) || 1;
form.project_id = props.editData.project_id || null;
parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p }));
products.value = (props.editData.products || []).map((row, i) => ({
...row,
__key: `row_edit_${i}_${Date.now()}`,
}));
if (form.project_id && !projectOptions.value.some((i) => String(i.id) === String(form.project_id))) {
projectOptions.value = [
{ id: form.project_id, project_name: form.project_name || `项目 #${form.project_id}` },
];
}
} else {
isEdit.value = false;
Object.assign(form, emptyForm());
form.contract_no = genContractNo();
projectOptions.value = [];
}
// 兜底补齐参与方并写入本公司方
const count = Number(form.party_count) || 2;
parties.value = PARTY_ROLES.slice(0, count).map(
(r) => parties.value.find((p) => p.role === r.key) || emptyParty(r.key)
);
applyOwnParty();
}
);
/* ------------------------------ 快速创建 ------------------------------ */
const quickVisible = ref(false);
const quickRefType = ref(1);
const quickPresetName = ref("");
const quickTargetRole = ref("");
const openQuickCreate = (role) => {
const party = getParty(role);
quickTargetRole.value = role;
quickRefType.value = Number(party.ref_type) === 2 ? 2 : 1;
quickPresetName.value = "";
quickVisible.value = true;
};
const handleQuickCreated = (row) => {
const party = getParty(quickTargetRole.value);
if (!row?.id) return;
party.ref_type = quickRefType.value;
party.ref_id = row.id;
party.ref_name = row.label || party.ref_name || "";
// 补充签约人默认信息
if (!party.signer_name && row.contact_person) party.signer_name = row.contact_person;
if (!party.signer_phone && row.contact_phone) party.signer_phone = row.contact_phone;
};
/* ------------------------------ 步骤与保存 ------------------------------ */
const prevStep = () => {
if (activeStep.value > 0) activeStep.value--;
};
/** 组装保存 payload;数值字段统一转数字(后端 int8 解析),summary 由产品清单实时计算 */
const buildPayload = (step, status) => ({
...form,
our_role: Number(form.our_role) || 2,
party_count: Number(form.party_count) || 2,
parties: parties.value.map((p) => ({ ...p })),
products: products.value.map(({ __key, ...rest }) => ({
...rest,
quantity: Number(rest.quantity) || 0,
price: Number(rest.price) || 0,
cost_price: Number(rest.cost_price) || 0,
})),
summary: buildSummary(products.value),
step,
status,
});
/** 进度式保存核心:草稿只校验合同名称;完成时全量校验 */
const saveContract = async ({ finish = false }) => {
if (!form.contract_name || !String(form.contract_name).trim()) {
ElMessage.warning("请先填写合同名称");
activeStep.value = 0;
return false;
}
if (finish) {
if (!formRef.value) return false;
try {
await formRef.value.validate();
} catch {
activeStep.value = 0;
ElMessage.warning("请先补全合同信息必填项");
return false;
}
}
saving.value = true;
try {
const step = finish ? 2 : Math.max(1, activeStep.value + 1);
// 向导保存一律为草稿,签订 / 履约等状态在列表「状态」中流转
const status = 1;
const payload = buildPayload(step, status);
if (internalId.value) {
await updateContract(internalId.value, payload);
} else {
const res = await createContract(payload);
const newId = res?.data?.id ?? res?.data?.contract_id;
if (newId) {
internalId.value = newId;
isEdit.value = true;
}
}
if (finish) {
ElMessage.success("合同已保存,可在列表中流转状态");
emit("success");
handleClose();
} else {
form.step = step;
form.status = status;
ElMessage.success(isEdit.value && internalId.value ? "进度已保存" : "草稿已保存,可继续填写");
}
return true;
} catch (e) {
ElMessage.error(e.message || "保存失败");
return false;
} finally {
saving.value = false;
}
};
/** 下一步:先自动保存当前进度,成功后前进 */
const nextStep = async () => {
const ok = await saveContract({ finish: false });
if (ok && activeStep.value < MAX_STEP) activeStep.value++;
};
/** 手动保存当前进度(不关窗,随时可存) */
const handleSaveProgress = async () => {
await saveContract({ finish: false });
};
/** 保存并完成 */
const handleFinish = async () => {
await saveContract({ finish: true });
};
const handleClose = () => emit("update:visible", false);
const handleClosed = () => {
formRef.value?.resetFields();
activeStep.value = 0;
internalId.value = null;
parties.value = [];
products.value = [];
Object.assign(form, emptyForm());
};
</script>
<style lang="less" scoped>
.steps-wrapper {
padding: 0 24px;
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
.footer-left,
.footer-right {
display: flex;
gap: 8px;
}
}
.parties-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 0 16px;
}
/* 合同参与方配置面板:合同形式 + 我方角色 */
.party-config {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px 56px;
margin: 0 16px 16px;
padding: 14px 20px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.config-block {
display: flex;
align-items: center;
gap: 12px;
}
.config-label {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
flex-shrink: 0;
}
.form-cards {
display: flex;
gap: 10px;
}
.form-card {
width: 78px;
padding: 7px 0 6px;
text-align: center;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color);
cursor: pointer;
user-select: none;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
gap: 2px;
&:hover {
border-color: var(--el-color-primary-light-5);
}
&.active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
box-shadow: 0 1px 4px rgba(64, 158, 255, 0.25);
.form-card-name {
color: var(--el-color-primary);
}
}
.form-card-name {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.3;
}
.form-card-roles {
font-size: 11px;
color: var(--el-text-color-secondary);
line-height: 1.3;
}
}
.config-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.nature-tip {
display: flex;
align-items: center;
gap: 6px;
margin: 12px 16px 0;
padding: 8px 12px;
border-radius: 4px;
background: var(--el-color-info-light-9);
color: var(--el-text-color-secondary);
font-size: 12px;
}
.option-name {
float: left;
}
.option-sub {
float: right;
margin-left: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,240 @@
<template>
<el-drawer
:model-value="visible"
title="合同详情"
size="680px"
:close-on-click-modal="false"
@update:model-value="emit('update:visible', $event)"
>
<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>
<div class="detail-sub">
{{ detail.contract_no }} · {{ contractCategoryText(detail.contract_category) }}
</div>
</div>
<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-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>
</template>
<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 {
formatMoney,
formatDateOnly,
contractCategoryText,
ourRoleText,
contractStatusText,
contractStatusTag,
productCategoryText,
productCategoryTag,
} from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
/** 行数据(含 id),详情从接口拉取完整数据 */
contract: { type: Object, default: null },
});
const emit = defineEmits(["update:visible"]);
const loading = ref(false);
const detail = ref({});
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 || "-";
watch(
() => props.visible,
async (val) => {
if (!val) return;
detail.value = {};
if (!props.contract?.id) return;
loading.value = true;
try {
const res = await getContractDetail(props.contract.id);
detail.value = res?.data || props.contract;
} catch {
detail.value = props.contract;
} finally {
loading.value = false;
}
}
);
</script>
<style lang="less" scoped>
.contract-detail {
padding: 0 4px;
}
.detail-head {
.detail-title {
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);
}
}
.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,62 @@
/**
* 合同组件工具:金额汇总计算(产品清单 → 各部分金额 / 总金额 / 总成本 / 总利润)
*
* 金额归属:
* - 硬件部分 = Σ 产品类别为「硬件(货物)」的小计
* - 软件部分 = Σ 产品类别为「软件(许可)」的小计
* - 其他部分 = Σ 服务 / 开发 / 其他类小计
* - 合同总金额 = 硬件 + 软件 + 其他
* - 产品总成本 = Σ (数量 × 成本单价)
* - 合同总利润 = 合同总金额 - 产品总成本
*/
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
export function buildSummary(products) {
const rows = Array.isArray(products) ? products : [];
let hardware = 0;
let software = 0;
let other = 0;
let totalCost = 0;
rows.forEach((row) => {
const qty = Number(row?.quantity) || 0;
const price = Number(row?.price) || 0;
const costPrice = Number(row?.cost_price) || 0;
const amount = round2(qty * price);
totalCost += round2(qty * costPrice);
const cat = String(row?.category || "");
if (cat === "1") hardware += amount;
else if (cat === "2") software += amount;
else other += amount;
});
hardware = round2(hardware);
software = round2(software);
other = round2(other);
const totalAmount = round2(hardware + software + other);
return {
hardware_amount: hardware,
software_amount: software,
other_amount: other,
total_amount: totalAmount,
total_cost: round2(totalCost),
total_profit: round2(totalAmount - round2(totalCost)),
};
}
/** 生成合同编号建议值:HT-YYYYMMDD-4位随机 */
export function genContractNo() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const rand = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
return `HT-${y}${m}${day}-${rand}`;
}
/** 甲乙丙丁角色 key,按 party_count 取前 n 个 */
export const PARTY_ROLES = [
{ key: "party_a", label: "甲方" },
{ key: "party_b", label: "乙方" },
{ key: "party_c", label: "丙方" },
{ key: "party_d", label: "丁方" },
];
@@ -0,0 +1,371 @@
<template>
<div class="crm-page">
<div class="page-header">
<div>
<h2>合同管理</h2>
<p>进度式创建合同,绑定项目或无头合同,管理甲乙丙丁各方与产品清单</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新建合同</el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<span class="stat-label">合同总数</span>
<span class="stat-value">{{ pagination.total }}</span>
</div>
<div class="stat-card">
<span class="stat-label">合同总金额</span>
<span class="stat-value 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>
</div>
<div class="stat-card">
<span class="stat-label">合同总利润</span>
<span class="stat-value success">¥{{ formatMoney(stats.total_profit) }}</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: 260px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="我方角色">
<el-select v-model="filters.our_role" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in OUR_ROLE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="合同分类">
<el-select v-model="filters.contract_category" clearable placeholder="全部" style="width: 130px">
<el-option v-for="i in CONTRACT_CATEGORY_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="归属">
<el-select v-model="filters.project_type" clearable placeholder="全部" style="width: 130px">
<el-option label="项目合同" value="project" />
<el-option label="无头合同" value="headless" />
</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 CONTRACT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border row-key="id">
<el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip />
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip fixed>
<template #default="{ row }">
<span class="name-link" @click="openRow(row)">
{{ row.contract_name }}
<el-tag v-if="Number(row.status) === 1" size="small" type="warning" effect="light">草稿</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="我方角色" width="90" align="center">
<template #default="{ row }">
<el-tag type="primary" size="small" effect="plain">{{ ourRoleText(row.our_role) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="100" align="center">
<template #default="{ row }">{{ contractCategoryText(row.contract_category) }}</template>
</el-table-column>
<el-table-column label="归属项目" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.project_name || "无" }}
</template>
</el-table-column>
<el-table-column label="甲方" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ partyName(row, "party_a") }}</template>
</el-table-column>
<el-table-column label="乙方" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ partyName(row, "party_b") }}</template>
</el-table-column>
<el-table-column label="合同总金额" width="130" align="right">
<template #default="{ row }">
{{ formatMoney(row.summary?.total_amount ?? row.total_amount) }}
</template>
</el-table-column>
<el-table-column label="合同总利润" width="130" align="right">
<template #default="{ row }">
{{ formatMoney(row.summary?.total_profit ?? row.total_profit) }}
</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="90" align="center">
<template #default="{ row }">
<el-tag :type="contractStatusTag(row.status)" size="small">
{{ contractStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">
{{ Number(row.status) === 1 ? "继续填写" : "编辑" }}
</el-button>
<!-- 状态流转:草稿 / 已完成 / 履约中 / 执行异常 / 已作废 互切 -->
<el-dropdown trigger="click" @command="(cmd) => handleChangeStatus(row, cmd)">
<el-button link type="warning" size="small">
状态<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="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无合同" :image-size="80" /></template>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="fetchList"
/>
</div>
</div>
<!-- 进度式创建 / 编辑向导 -->
<ContractCreate
v-model:visible="createVisible"
:edit-data="currentRow"
:init-step="initStep"
@success="fetchList"
/>
<!-- 合同详情 -->
<ContractDetail v-model:visible="detailVisible" :contract="currentRow" />
</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 {
getContractList,
getContractStats,
changeContractStatus,
deleteContract,
} from "@/api/crmContract";
import ContractCreate from "./components/create.vue";
import ContractDetail from "./components/detail.vue";
import {
CONTRACT_CATEGORY_OPTIONS,
OUR_ROLE_OPTIONS,
CONTRACT_STATUS_OPTIONS,
ourRoleText,
contractCategoryText,
contractStatusText,
contractStatusTag,
formatDateOnly,
formatMoney,
} from "../dict";
const loading = ref(false);
const tableData = ref([]);
const createVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
const initStep = ref(1);
const filters = reactive({
keyword: "",
our_role: "",
contract_category: "",
project_type: "",
status: "",
});
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 列表页统计:后端全租户汇总(排除已作废) */
const stats = ref({ total_amount: 0, total_cost: 0, total_profit: 0 });
async function loadStats() {
try {
const res = await getContractStats();
stats.value = res?.data || stats.value;
} catch {
/* 统计失败不影响列表 */
}
}
onMounted(() => {
fetchList();
loadStats();
});
async function fetchList() {
loading.value = true;
try {
const res = await getContractList({
page: pagination.page,
pageSize: pagination.pageSize,
...filters,
});
tableData.value = res?.data?.list || [];
pagination.total = res?.data?.total || 0;
loadStats(); // 新增/删除后统计随之刷新
} 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.our_role = "";
filters.contract_category = "";
filters.project_type = "";
filters.status = "";
handleSearch();
}
/** 从行数据的 parties 里取某一方签约主体名称 */
function partyName(row, role) {
const hit = (row.parties || []).find((p) => p.role === role);
return hit?.ref_name || "-";
}
function openCreate() {
currentRow.value = null;
initStep.value = 1;
createVisible.value = true;
}
/** 草稿:按保存的进度定位步骤;已完成:从第一步编辑 */
function openEdit(row) {
currentRow.value = { ...row };
initStep.value = Number(row.step) === 2 ? 2 : 1;
createVisible.value = true;
}
function openDetail(row) {
currentRow.value = { ...row };
detailVisible.value = true;
}
/** 点击名称:草稿继续填写,已完成看详情 */
function openRow(row) {
if (Number(row.status) === 1) openEdit(row);
else 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 || "状态更新失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除合同「${row.contract_name}」吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deleteContract(row.id);
ElMessage.success("删除成功");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
</script>
<style lang="less" scoped src="../styles/crm-page.less"></style>
<style lang="less" scoped>
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 12px;
.stat-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 6px;
.stat-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
&.primary {
color: var(--el-color-primary);
}
&.success {
color: var(--el-color-success);
}
}
}
}
</style>
+104
View File
@@ -263,6 +263,110 @@ export function formatDateOnly(val) {
return `${d.getFullYear()}-${m}-${day}`;
}
/* =====================================================================
* 合同管理
* 我方角色 our_role:1=甲方 2=乙方 3=丙方 4=丁方(当前租户扮演的一方,默认乙方)
* 合同形式 party_count:2=双方(甲乙)3=三方(甲乙丙)4=四方(甲乙丙丁)
* 参与方来源 ref_type:1=客户 2=供应商 0=未关联
* ===================================================================== */
/** 合同分类 */
export const CONTRACT_CATEGORY_OPTIONS = [
{ label: "开发合同", value: "1" },
{ label: "服务合同", value: "2" },
{ label: "销售合同", value: "3" },
{ label: "租赁合同", value: "4" },
{ label: "采购合同", value: "5" },
{ label: "运维合同", value: "6" },
{ label: "咨询合同", value: "7" },
{ label: "其他", value: "8" },
];
/**
* 我方角色:当前租户在合同中扮演的参与方(甲乙丙丁)。
* 1=甲方 2=乙方 3=丙方 4=丁方,默认乙方。
*/
export const OUR_ROLE_OPTIONS = [
{ label: "甲方", value: 1 },
{ label: "乙方", value: 2 },
{ label: "丙方", value: 3 },
{ label: "丁方", value: 4 },
];
/** 合同形式(参与方数量) */
export const CONTRACT_PARTY_COUNT_OPTIONS = [
{ label: "双方合同", value: 2 },
{ label: "三方合同", value: 3 },
{ label: "四方合同", value: 4 },
];
/** 合同状态:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常 */
export const CONTRACT_STATUS_OPTIONS = [
{ label: "草稿", value: "1" },
{ label: "已完成", value: "2" },
{ label: "履约中", value: "4" },
{ label: "执行异常", value: "5" },
{ label: "已作废", value: "3" },
];
/** 产品类别:金额归属 硬件/软件 归各自部分,其余计入其他部分 */
export const CONTRACT_PRODUCT_CATEGORY_OPTIONS = [
{ label: "硬件(货物)", value: "1" },
{ label: "软件(许可)", value: "2" },
{ label: "服务", value: "3" },
{ label: "开发", value: "4" },
{ label: "其他", value: "5" },
];
/** 参与方角色(甲乙丙丁) */
export const PARTY_ROLE_OPTIONS = [
{ key: "party_a", label: "甲方" },
{ key: "party_b", label: "乙方" },
{ key: "party_c", label: "丙方" },
{ key: "party_d", label: "丁方" },
];
const CONTRACT_CATEGORY_MAP = CONTRACT_CATEGORY_OPTIONS.reduce(
(m, i) => ((m[i.value] = i.label), m),
{}
);
const OUR_ROLE_MAP = OUR_ROLE_OPTIONS.reduce(
(m, i) => ((m[i.value] = i.label), m),
{}
);
const CONTRACT_STATUS_MAP = CONTRACT_STATUS_OPTIONS.reduce(
(m, i) => ((m[i.value] = i.label), m),
{}
);
const PRODUCT_CATEGORY_MAP = CONTRACT_PRODUCT_CATEGORY_OPTIONS.reduce(
(m, i) => ((m[i.value] = i.label), m),
{}
);
const CONTRACT_STATUS_TAG = { 1: "warning", 2: "success", 3: "info", 4: "primary", 5: "danger" };
const PRODUCT_CATEGORY_TAG = { 1: "warning", 2: "primary", 3: "success", 4: "danger", 5: "info" };
export const contractCategoryText = (val) =>
CONTRACT_CATEGORY_MAP[normalize(val)] || normalize(val) || "-";
/** 我方角色文案:1甲方/2乙方/3丙方/4丁方 */
export const ourRoleText = (val) =>
OUR_ROLE_MAP[normalize(val)] || normalize(val) || "-";
export const ourRoleTag = () => "primary";
export const contractStatusText = (val) =>
CONTRACT_STATUS_MAP[normalize(val)] || normalize(val) || "-";
export const contractStatusTag = (val) => CONTRACT_STATUS_TAG[normalize(val)] || "info";
export const productCategoryText = (val) =>
PRODUCT_CATEGORY_MAP[normalize(val)] || normalize(val) || "-";
export const productCategoryTag = (val) => PRODUCT_CATEGORY_TAG[normalize(val)] || "info";
/** 合同分类选项(供 el-select 遍历) */
export const contractCategoryOptions = CONTRACT_CATEGORY_OPTIONS;
export const contractStatusOptions = CONTRACT_STATUS_OPTIONS;
export const ourRoleOptions = OUR_ROLE_OPTIONS;
export const contractPartyCountOptions = CONTRACT_PARTY_COUNT_OPTIONS;
export const contractProductCategoryOptions = CONTRACT_PRODUCT_CATEGORY_OPTIONS;
/** 富文本转纯文本预览(用于列表展示,含图片时返回 [图片]) */
export function stripHtml(val) {
if (!val) return "-";
@@ -0,0 +1,5 @@
<template>
<div />
</template>
<script setup></script>
+604
View File
@@ -0,0 +1,604 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"math/rand"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendCrmContractController CRM 合同管理
//
// 进度式创建:前端每完成一步即可保存(Create / Update 均支持),
// step 记录创建进度(1=合同信息 2=产品清单),status=1 表示草稿、2 表示已完成。
type BackendCrmContractController struct {
beego.Controller
}
// contractSummary 金额汇总(后端按 products 重算,与前端展示逻辑一致)。
type contractSummary struct {
HardwareAmount float64 `json:"hardware_amount"` // 硬件部分金额
SoftwareAmount float64 `json:"software_amount"` // 软件部分金额
OtherAmount float64 `json:"other_amount"` // 其他部分金额(服务/开发/其他)
TotalAmount float64 `json:"total_amount"` // 合同总金额 = 硬件 + 软件 + 其他
TotalCost float64 `json:"total_cost"` // 产品总成本 = Σ(数量 × 成本单价)
TotalProfit float64 `json:"total_profit"` // 合同总利润 = 总金额 - 总成本
}
// contractPayload 创建 / 更新请求体。
type contractPayload struct {
ContractNo string `json:"contract_no"`
ContractName string `json:"contract_name"`
ContractCategory string `json:"contract_category"`
OurRole int8 `json:"our_role"` // 我方角色:1甲方/2乙方/3丙方/4丁方
PartyCount int8 `json:"party_count"`
ProjectID uint64 `json:"project_id"`
ProjectName string `json:"project_name"`
OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"`
SignDate string `json:"sign_date"`
EffectiveDate string `json:"effective_date"`
ExpireDate string `json:"expire_date"`
Parties json.RawMessage `json:"parties"`
Products json.RawMessage `json:"products"`
Step int8 `json:"step"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
// contractResp 列表 / 详情响应:parties / products 解析为 JSON 数组透出,summary 由金额字段组装。
type contractResp struct {
models.TenantCrmContract
Parties json.RawMessage `json:"parties"`
Products json.RawMessage `json:"products"`
Summary *contractSummary `json:"summary"`
}
// List GET /backend/crm/contract/list
func (c *BackendCrmContractController) 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"))
ourRole := strings.TrimSpace(c.GetString("our_role"))
category := strings.TrimSpace(c.GetString("contract_category"))
projectType := strings.TrimSpace(c.GetString("project_type"))
status := strings.TrimSpace(c.GetString("status"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
if keyword != "" {
// 签约主体名称存储在 parties JSON 中,用 LIKE 一并匹配
kw := orm.NewCondition().
Or("contract_name__contains", keyword).
Or("contract_no__contains", keyword).
Or("project_name__contains", keyword).
Or("owner_user_name__contains", keyword).
Or("parties__contains", keyword)
cond = cond.AndCond(kw)
}
if ourRole != "" {
cond = cond.And("our_role", ourRole)
}
if category != "" {
cond = cond.And("contract_category", category)
}
if status != "" {
cond = cond.And("status", status)
}
switch projectType {
case "project": // 项目合同
cond = cond.And("project_id__gt", 0)
case "headless": // 无头合同
cond = cond.And("project_id__isnull", true)
}
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmContract
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
items := make([]contractResp, 0, len(list))
for i := range list {
items = append(items, buildContractResp(&list[i]))
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": items, "total": total, "page": page, "pageSize": pageSize,
})
}
// Stats GET /backend/crm/contract/stats
// 全租户合同统计(排除已作废),供列表页顶部汇总卡片使用。
func (c *BackendCrmContractController) Stats() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
tenantID := pipelineTenantID(claims)
table := new(models.TenantCrmContract).TableName()
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"
var rows []orm.Params
if _, err := models.Orm.Raw(raw, tenantID).Values(&rows); err != nil || len(rows) == 0 {
pipelineOk(&c.Controller, contractSummary{TotalProfit: 0})
return
}
r := rows[0]
pipelineOk(&c.Controller, map[string]interface{}{
"total": toInt64(r["total"]),
"total_amount": toFloat64(r["total_amount"]),
"total_cost": toFloat64(r["total_cost"]),
"total_profit": toFloat64(r["total_profit"]),
})
}
// Detail GET /backend/crm/contract/:id
func (c *BackendCrmContractController) 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.TenantCrmContract
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
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, buildContractResp(&row))
}
// Create POST /backend/crm/contract
// 进度式保存入口之一:首次保存(通常为草稿),返回 id 后续走 Update。
func (c *BackendCrmContractController) 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 contractPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if strings.TrimSpace(p.ContractName) == "" {
pipelineErr(&c.Controller, 400, 400, "合同名称不能为空")
return
}
tenantID := pipelineTenantID(claims)
now := time.Now()
row := models.TenantCrmContract{
TenantID: tenantID,
ContractNo: strings.TrimSpace(p.ContractNo),
ContractName: strings.TrimSpace(p.ContractName),
OurRole: pickInt8(p.OurRole, 2, 4),
PartyCount: pickInt8(p.PartyCount, 2, 4),
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
SignDate: parsePipelineDate(p.SignDate),
EffectiveDate: parsePipelineDate(p.EffectiveDate),
ExpireDate: parsePipelineDate(p.ExpireDate),
Status: pickInt8(p.Status, 1, 5),
Step: pickInt8(p.Step, 1, 2),
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
}
if strings.TrimSpace(p.ContractCategory) != "" {
row.ContractCategory = strings.TrimSpace(p.ContractCategory)
}
// 编号为空时自动生成(查重)
if row.ContractNo == "" {
row.ContractNo = genContractNo(tenantID)
}
// 绑定项目:校验项目归属并取标准项目名称
if p.ProjectID > 0 {
projID, projName, ok := c.resolveProject(tenantID, p.ProjectID)
if !ok {
pipelineErr(&c.Controller, 400, 400, "关联项目不存在")
return
}
row.ProjectID = &projID
row.ProjectName = projName
}
// 参与方 / 产品清单 JSON 落库 + 金额重算
parties, err := normalizeContractJSON(p.Parties)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "参与方数据格式错误")
return
}
row.Parties = parties
products, err := normalizeContractJSON(p.Products)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "产品清单数据格式错误")
return
}
row.Products = products
applyContractAmounts(&row)
if _, err := models.Orm.Insert(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(tenantID, 3, row.ID, "create", "创建合同:"+row.ContractName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
// Update PUT /backend/crm/contract/:id
// 进度式保存入口之一:每一步保存都走这里,全量覆盖业务字段。
func (c *BackendCrmContractController) 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 contractPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmContract
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
if strings.TrimSpace(p.ContractName) == "" {
pipelineErr(&c.Controller, 400, 400, "合同名称不能为空")
return
}
row.ContractName = strings.TrimSpace(p.ContractName)
if no := strings.TrimSpace(p.ContractNo); no != "" {
row.ContractNo = no
}
if strings.TrimSpace(p.ContractCategory) != "" {
row.ContractCategory = strings.TrimSpace(p.ContractCategory)
}
if p.OurRole != 0 {
row.OurRole = p.OurRole
}
if p.PartyCount != 0 {
row.PartyCount = p.PartyCount
}
// 项目绑定支持切换 / 解绑(清空即为无头合同)
if p.ProjectID > 0 {
projID, projName, ok := c.resolveProject(tenantID, p.ProjectID)
if !ok {
pipelineErr(&c.Controller, 400, 400, "关联项目不存在")
return
}
row.ProjectID = &projID
row.ProjectName = projName
} else {
row.ProjectID = nil
row.ProjectName = ""
}
if strings.TrimSpace(p.OwnerUserID) != "" {
row.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
}
if strings.TrimSpace(p.OwnerUserName) != "" {
row.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
}
row.SignDate = parsePipelineDate(p.SignDate)
row.EffectiveDate = parsePipelineDate(p.EffectiveDate)
row.ExpireDate = parsePipelineDate(p.ExpireDate)
if p.Parties != nil {
parties, err := normalizeContractJSON(p.Parties)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "参与方数据格式错误")
return
}
row.Parties = parties
}
if p.Products != nil {
products, err := normalizeContractJSON(p.Products)
if err != nil {
pipelineErr(&c.Controller, 400, 400, "产品清单数据格式错误")
return
}
row.Products = products
}
applyContractAmounts(&row)
if p.Status != 0 {
row.Status = p.Status
}
if p.Step != 0 {
row.Step = p.Step
}
row.Remark = p.Remark
row.UpdateTime = time.Now()
if _, err := models.Orm.Update(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
crmWriteLog(tenantID, 3, row.ID, "update", "更新合同:"+row.ContractName, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
// Delete DELETE /backend/crm/contract/:id
func (c *BackendCrmContractController) 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.TenantCrmContract
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
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.TenantCrmContract)).
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)
}
// ChangeStatus POST /backend/crm/contract/:id/status
// 合同状态流转:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常。
// 向导创建的合同默认为草稿,签订 / 履约等状态在列表中手动流转;各状态间可互切。
func (c *BackendCrmContractController) ChangeStatus() {
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 {
Status int8 `json:"status"`
}
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
// 合法目标状态:2已完成 / 3已作废 / 4履约中 / 5执行异常
if p.Status < 2 || p.Status > 5 {
pipelineErr(&c.Controller, 400, 400, "无效的状态值")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmContract
if err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "合同未找到")
return
}
if row.Status == p.Status {
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": row.Status})
return
}
if _, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(map[string]interface{}{"status": p.Status, "update_time": time.Now()}); err != nil {
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
return
}
crmWriteLog(tenantID, 3, row.ID, "status", fmt.Sprintf("合同状态流转:%s → %s", contractStatusName(row.Status), contractStatusName(p.Status)), claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": p.Status})
}
// contractStatusName 状态文案(用于操作日志)。
func contractStatusName(s int8) string {
switch s {
case 1:
return "草稿"
case 2:
return "已完成"
case 3:
return "已作废"
case 4:
return "履约中"
case 5:
return "执行异常"
default:
return "未知"
}
}
// ========================== 内部辅助 ==========================
// buildContractResp 组装响应:parties / products 透传 JSON 数组,summary 由金额字段组装。
func buildContractResp(row *models.TenantCrmContract) contractResp {
parties := json.RawMessage("[]")
if strings.TrimSpace(row.Parties) != "" {
parties = json.RawMessage(row.Parties)
}
products := json.RawMessage("[]")
if strings.TrimSpace(row.Products) != "" {
products = json.RawMessage(row.Products)
}
return contractResp{
TenantCrmContract: *row,
Parties: parties,
Products: products,
Summary: &contractSummary{
HardwareAmount: row.HardwareAmount,
SoftwareAmount: row.SoftwareAmount,
OtherAmount: row.OtherAmount,
TotalAmount: row.TotalAmount,
TotalCost: row.TotalCost,
TotalProfit: row.TotalProfit,
},
}
}
// resolveProject 校验项目归属当前租户并返回 (id, 标准项目名称)。
func (c *BackendCrmContractController) resolveProject(tenantID string, projectID uint64) (uint64, string, bool) {
var proj models.TenantCrmProject
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", projectID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&proj); err != nil {
return 0, "", false
}
return proj.ID, proj.ProjectName, true
}
// normalizeContractJSON 校验并规范化 JSON 数组(参与方 / 产品清单),返回紧凑 JSON 文本。
func normalizeContractJSON(raw json.RawMessage) (string, error) {
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" {
return "[]", nil
}
var arr []map[string]interface{}
if err := json.Unmarshal(raw, &arr); err != nil {
return "", err
}
out, err := json.Marshal(arr)
if err != nil {
return "", err
}
return string(out), nil
}
// applyContractAmounts 按产品清单重算各部分金额(与前端 ProductList 汇总口径一致):
// 硬件部分=Σ硬件小计;软件部分=Σ软件小计;其他部分=Σ服务/开发/其他小计;
// 合同总金额=硬件+软件+其他;产品总成本=Σ(数量×成本单价);合同总利润=总金额-总成本。
func applyContractAmounts(row *models.TenantCrmContract) {
var items []map[string]interface{}
if strings.TrimSpace(row.Products) != "" {
_ = json.Unmarshal([]byte(row.Products), &items)
}
var hardware, software, other, cost float64
for _, item := range items {
qty := toFloat64(item["quantity"])
price := toFloat64(item["price"])
costPrice := toFloat64(item["cost_price"])
amount := round2(qty * price)
cost = round2(cost + round2(qty*costPrice))
cat := fmt.Sprintf("%v", item["category"])
switch cat {
case "1":
hardware = round2(hardware + amount)
case "2":
software = round2(software + amount)
default:
other = round2(other + amount)
}
}
row.HardwareAmount = hardware
row.SoftwareAmount = software
row.OtherAmount = other
row.TotalAmount = round2(hardware + software + other)
row.TotalCost = cost
row.TotalProfit = round2(row.TotalAmount - cost)
}
// genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。
func genContractNo(tenantID string) string {
for i := 0; i < 5; i++ {
no := fmt.Sprintf("HT-%s-%04d", time.Now().Format("20060102"), rand.Intn(10000))
count, _ := models.Orm.QueryTable(new(models.TenantCrmContract)).
Filter("tenant_id", tenantID).Filter("contract_no", no).
Filter("delete_time__isnull", true).Count()
if count == 0 {
return no
}
}
return fmt.Sprintf("HT-%s-%d", time.Now().Format("20060102"), time.Now().UnixNano()%100000)
}
// pickInt8 取值约束:v 落在 [min, max] 内返回 v,否则返回 def。
func pickInt8(v, def, max int8) int8 {
if v >= 1 && v <= max {
return v
}
return def
}
// round2 保留两位小数。
func round2(n float64) float64 {
return float64(int64((n+1e-9)*100+0.5)) / 100
}
// toInt64 orm.Params 值转 int64。
func toInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case []byte:
x, _ := strconv.ParseInt(strings.TrimSpace(string(n)), 10, 64)
return x
case string:
x, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return x
}
return 0
}
// toFloat64 orm.Params / JSON 数值转 float64。
func toFloat64(v interface{}) float64 {
switch n := v.(type) {
case float64:
return n
case int64:
return float64(n)
case []byte:
x, _ := strconv.ParseFloat(strings.TrimSpace(string(n)), 64)
return x
case string:
x, _ := strconv.ParseFloat(strings.TrimSpace(n), 64)
return x
}
return 0
}
+58
View File
@@ -81,6 +81,7 @@ func Init(_ string) {
new(TenantCrmAttach),
new(TenantCrmEntityContact),
new(TenantCrmOperateLog),
new(TenantCrmContract),
new(ErpAccountSet),
new(ErpNormalSetting),
new(ErpCompanyContact),
@@ -142,6 +143,63 @@ func Init(_ string) {
EnsureCrmCustomerPoolColumns()
EnsureCrmCreateUserColumn()
EnsureCrmProjectDocColumn()
EnsureCrmContractTable()
EnsureCrmContractOurRoleColumn()
}
// EnsureCrmContractOurRoleColumn 补齐合同表的我方角色字段(存量表已建时新增;
// 旧版「合同性质 contract_nature」字段弃用但保留不动,表不存在或列已存在时忽略错误)。
func EnsureCrmContractOurRoleColumn() {
sql := "ALTER TABLE " + new(TenantCrmContract).TableName() +
" ADD COLUMN our_role tinyint NOT NULL DEFAULT 2 COMMENT '我方角色:1甲方/2乙方/3丙方/4丁方'"
_, _ = Orm.Raw(sql).Exec()
}
// EnsureCrmContractTable 合同表建表(CREATE TABLE IF NOT EXISTS,可重复执行;
// 建表失败(如表已存在但结构不一致)时静默忽略,可用 sql/yz_backend_crm_contract.sql 手动修复)。
func EnsureCrmContractTable() {
sql := `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,空=无头合同',
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',
products text COMMENT '产品清单JSON',
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合同表'`
if _, err := Orm.Raw(sql).Exec(); err != nil {
// 表已存在或执行失败时忽略(完整表结构见 sql/yz_backend_crm_contract.sql)
return
}
}
// EnsureCrmProjectDocColumn 补齐项目表的文档库文件夹分类字段(项目文档绑定 OA 文档库)。
+47
View File
@@ -0,0 +1,47 @@
package models
import "time"
// TenantCrmContract 合同表: yz_backend_crm_contract
//
// 说明:
// - project_id 为空即为「无头合同」,否则为「项目合同」;
// - our_role:我方角色,当前租户扮演的参与方,1=甲方 2=乙方 3=丙方 4=丁方(默认乙方);
// - party_count:2=双方(甲乙)3=三方(甲乙丙)4=四方(甲乙丙丁);
// - parties / products 以 JSON 文本存储,返回时由控制器解析后透出;
// - 各部分金额冗余为表字段,便于列表统计与排序(与 products 重算结果一致)。
type TenantCrmContract struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
ContractNo string `orm:"column(contract_no);size(50)" json:"contract_no"` // 合同编号
ContractName string `orm:"column(contract_name);size(100)" json:"contract_name"` // 合同名称
ContractCategory string `orm:"column(contract_category);size(20)" json:"contract_category"` // 分类:1开发/2服务/3销售/4租赁/5采购/6运维/7咨询/8其他
OurRole int8 `orm:"column(our_role);default(2)" json:"our_role"` // 我方角色:1甲方/2乙方/3丙方/4丁方
PartyCount int8 `orm:"column(party_count);default(2)" json:"party_count"` // 合同形式:2/3/4 方
ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 关联项目ID(空=无头合同)
ProjectName string `orm:"column(project_name);size(100)" json:"project_name"`
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"`
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"`
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"`
SoftwareAmount float64 `orm:"column(software_amount);digits(14);decimals(2);default(0)" json:"software_amount"`
OtherAmount float64 `orm:"column(other_amount);digits(14);decimals(2);default(0)" json:"other_amount"`
TotalAmount float64 `orm:"column(total_amount);digits(14);decimals(2);default(0)" json:"total_amount"`
TotalCost float64 `orm:"column(total_cost);digits(14);decimals(2);default(0)" json:"total_cost"`
TotalProfit float64 `orm:"column(total_profit);digits(14);decimals(2);default(0)" json:"total_profit"`
Status int8 `orm:"column(status);default(1)" json:"status"` // 1草稿/2已完成/3已作废/4履约中/5执行异常
Step int8 `orm:"column(step);default(1)" json:"step"` // 进度式创建步骤:1合同信息/2产品清单
Remark string `orm:"column(remark);type(text);null" json:"remark"`
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *TenantCrmContract) TableName() string {
return "yz_backend_crm_contract"
}
+7
View File
@@ -408,6 +408,13 @@ func registerOrganizationRoutes(module string) {
beego.Router("/backend/crm/project/:id/doc-folder", &controllers.BackendCrmProjectController{}, "post:DocFolderCreate")
beego.Router("/backend/crm/project/:id/doc-folder-delete", &controllers.BackendCrmProjectController{}, "post:DocFolderDelete")
// CRM合同管理(进度式创建:合同信息/产品清单每步可保存;绑定项目或无头合同)
beego.Router("/backend/crm/contract/list", &controllers.BackendCrmContractController{}, "get:List")
beego.Router("/backend/crm/contract/stats", &controllers.BackendCrmContractController{}, "get:Stats")
beego.Router("/backend/crm/contract", &controllers.BackendCrmContractController{}, "post:Create")
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/follow/list", &controllers.BackendCrmFollowController{}, "get:List")
beego.Router("/backend/crm/follow/add", &controllers.BackendCrmFollowController{}, "post:Add")
+53
View File
@@ -0,0 +1,53 @@
-- =============================================================================
-- 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合同表';