新增合同管理相关功能
This commit is contained in:
Vendored
+2
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 文档库)。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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合同表';
|
||||
Reference in New Issue
Block a user