feat: P1批次4 CRM+仓库域(客户跟进/业务员业绩/余料/领料/采购单)

This commit is contained in:
HHY
2026-09-25 06:47:20 -07:00
parent fbe21c17b5
commit 543a94609c
12 changed files with 636 additions and 7 deletions
+127 -4
View File
@@ -1,5 +1,8 @@
<template>
<el-card>
<el-tabs v-model="tab">
<!-- 客户列表 -->
<el-tab-pane label="客户列表" name="list">
<div class="toolbar">
<el-input v-model="keyword" placeholder="姓名/电话/地址" clearable style="width:240px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button>
@@ -18,15 +21,60 @@
<el-table-column prop="address" label="地址" min-width="180" />
<el-table-column prop="salesmen" label="业务员" width="100" />
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="140" fixed="right">
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" plain @click="openFollow(row)">跟进</el-button>
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
</el-tab-pane>
<!-- 业务员体系 -->
<el-tab-pane label="业务员" name="salesmen">
<el-row :gutter="14">
<el-col :span="10">
<el-card shadow="never" header="业务员名册">
<el-table :data="salesmen" size="small" border>
<el-table-column prop="name" label="姓名" min-width="80" />
<el-table-column prop="phone" label="电话" width="110" />
<el-table-column prop="commission_rate" label="提成%" width="70" />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button size="small" text type="danger" @click="removeSalesman(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<div class="sm-add">
<el-input v-model="smForm.name" size="small" placeholder="姓名" style="width:80px" />
<el-input v-model="smForm.phone" size="small" placeholder="电话" style="width:110px" />
<el-input-number v-model="smForm.commission_rate" size="small" :min="0" :max="100" style="width:90px" controls-position="right" />
<el-button size="small" type="primary" plain @click="addSalesman">添加</el-button>
</div>
</el-card>
</el-col>
<el-col :span="14">
<el-card shadow="never" header="业绩统计(名下客户/合同/预估提成)">
<el-table :data="smStats" size="small" border>
<el-table-column prop="name" label="业务员" width="90" />
<el-table-column prop="customer_count" label="客户数" width="80" />
<el-table-column prop="contract_count" label="合同数" width="80" />
<el-table-column label="合同总额" width="120">
<template #default="{ row }">¥{{ row.contract_money }}</template>
</el-table-column>
<el-table-column label="预估提成">
<template #default="{ row }">¥{{ row.commission }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!smStats.length" description="暂无在职业务员" :image-size="50" />
</el-card>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="editVisible" :title="form.id ? '编辑客户' : '新增客户'" width="480px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="70px">
@@ -35,6 +83,11 @@
<el-input v-model="form.phone" maxlength="20" />
</el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="业务员">
<el-select v-model="form.salesmen" clearable style="width:160px" placeholder="选择业务员">
<el-option v-for="s in salesmen" :key="s.id" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.note" type="textarea" /></el-form-item>
</el-form>
<template #footer>
@@ -42,6 +95,24 @@
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
<!-- 跟进记录(customer_records 语义) -->
<el-dialog v-model="followVisible" :title="`跟进记录 — ${followCustomer?.name ?? ''}`" width="520px">
<el-timeline style="padding-left:2px; max-height:260px; overflow-y:auto">
<el-timeline-item v-for="f in follows" :key="f.id" :timestamp="f.created_at" placement="top">
<div>{{ f.content }}</div>
<div v-if="f.next_date" class="follow-next">下次跟进:{{ f.next_date }}</div>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!follows.length" description="暂无跟进记录" :image-size="50" />
<el-divider content-position="left">新增跟进</el-divider>
<el-input v-model="followForm.content" type="textarea" placeholder="跟进内容" />
<div class="follow-add">
<el-date-picker v-model="followForm.next_date" type="date" value-format="YYYY-MM-DD"
placeholder="下次跟进日期(可选)" style="width:180px" />
<el-button type="primary" @click="addFollow">记录</el-button>
</div>
</el-dialog>
</el-card>
</template>
@@ -58,7 +129,7 @@ const keyword = ref('')
const loading = ref(false)
const editVisible = ref(false)
const formRef = ref()
const form = reactive({ id: 0, name: '', phone: '', address: '', note: '' })
const form = reactive<any>({ id: 0, name: '', phone: '', address: '', note: '', salesmen: '' })
const rules = {
name: [{ required: true, message: '请输入客户姓名', trigger: 'blur' }],
phone: [{ pattern: /^[0-9+\-\s]{5,20}$/, message: '电话格式不正确', trigger: 'blur' }],
@@ -74,7 +145,7 @@ async function load() {
}
function openEdit(row?: any) {
Object.assign(form, { id: row?.id ?? 0, name: row?.name ?? '', phone: row?.phone ?? '', address: row?.address ?? '', note: row?.note ?? '' })
Object.assign(form, { id: row?.id ?? 0, name: row?.name ?? '', phone: row?.phone ?? '', address: row?.address ?? '', note: row?.note ?? '', salesmen: row?.salesmen ?? '' })
editVisible.value = true
}
@@ -109,9 +180,61 @@ async function doImport(opt: any) {
function downloadTemplate() { apiDownload('/api/customers/excel-template', 'customers_template.xlsx') }
function exportExcel() { apiDownload('/api/customers/excel-out', 'customers.xlsx') }
onMounted(load)
// ---- 业务员体系 ----
const tab = ref('list')
const salesmen = ref<any[]>([])
const smStats = ref<any[]>([])
const smForm = reactive<any>({ name: '', phone: '', commission_rate: 0 })
async function loadSalesmen() {
salesmen.value = (await api<any[]>('get', '/api/salesmen')) ?? []
smStats.value = (await api<any[]>('get', '/api/salesmen/statistics')) ?? []
}
async function addSalesman() {
if (!smForm.name.trim()) return ElMessage.warning('请填写姓名')
await api('post', '/api/salesmen', smForm)
ElMessage.success('已添加')
smForm.name = ''; smForm.phone = ''; smForm.commission_rate = 0
loadSalesmen()
}
async function removeSalesman(row: any) {
await ElMessageBox.confirm(`确定删除业务员「${row.name}」?`, '提示', { type: 'warning' })
await api('delete', `/api/salesmen/${row.id}`)
loadSalesmen()
}
// ---- 跟进记录 ----
const followVisible = ref(false)
const followCustomer = ref<any>(null)
const follows = ref<any[]>([])
const followForm = reactive<any>({ content: '', next_date: null })
function openFollow(row: any) {
followCustomer.value = row
followForm.content = ''; followForm.next_date = null
loadFollows()
followVisible.value = true
}
async function loadFollows() {
follows.value = (await api<any[]>('get', `/api/customers/${followCustomer.value.id}/follows`)) ?? []
}
async function addFollow() {
if (!followForm.content.trim()) return ElMessage.warning('请填写跟进内容')
await api('post', `/api/customers/${followCustomer.value.id}/follows`, followForm)
followForm.content = ''; followForm.next_date = null
loadFollows()
}
onMounted(() => { load(); loadSalesmen() })
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.sm-add { display: flex; gap: 6px; margin-top: 10px; align-items: center; }
.follow-next { color: #e6a23c; font-size: 12px; margin-top: 2px; }
.follow-add { display: flex; gap: 10px; margin-top: 8px; justify-content: flex-end; }
</style>
+28
View File
@@ -15,6 +15,18 @@
</template>
<el-button type="primary" :loading="loading" :disabled="!contractId" @click="run">生成下料方案</el-button>
<el-button v-if="result" type="success" plain @click="print">打印方案</el-button>
<el-divider direction="vertical" />
<el-popconfirm title="把方案余料(≥最小长度)入库?" confirm-button-text="入库" @confirm="surplusIn">
<template #reference><el-button v-if="result" size="small" type="warning" plain>余料入库</el-button></template>
</el-popconfirm>
<el-popconfirm title="按领料清单从库存出库?" confirm-button-text="领料" @confirm="pickout">
<template #reference><el-button v-if="result" size="small" type="primary" plain>领料出库</el-button></template>
</el-popconfirm>
<el-popconfirm title="按缺料清单生成采购单?" confirm-button-text="生成" @confirm="createPurchase">
<template #reference>
<el-button v-if="result && result.shortage?.length" size="small" type="danger" plain>缺料转采购</el-button>
</template>
</el-popconfirm>
</div>
<template v-if="result">
@@ -96,6 +108,22 @@ async function run() {
function print() { window.print() }
// ---- 余料入库 / 领料 / 采购联动 ----
async function surplusIn() {
const d = await api<any>('post', '/api/warehouse/surplus-in', { contract_id: contractId.value, min_length: 300 })
ElMessage.success(d.added ? `${d.added} 根余料已入库` : '无达标余料')
}
async function pickout() {
const d = await api<any>('post', '/api/warehouse/pickout', { contract_id: contractId.value, note: '车间切割' })
ElMessage.success(`领料完成:${(d.picked ?? []).length} 项出库`)
}
async function createPurchase() {
const d = await api<any>('post', '/api/purchases', { contract_id: contractId.value, note: '下料缺料转采购' })
ElMessage.success(`采购单 ${d.po_no} 已生成,可在仓库库存页管理`)
}
onMounted(async () => {
const d = await api<any>('get', '/api/contracts?page=1&page_size=100')
contracts.value = d.contracts?.data ?? []
+78 -1
View File
@@ -1,5 +1,8 @@
<template>
<el-card>
<el-tabs v-model="tab">
<!-- 库存 -->
<el-tab-pane label="库存" name="stock">
<div class="toolbar">
<el-input v-model="keyword" placeholder="名称/编码" clearable style="width:200px" @keyup.enter="load" />
<el-select v-model="material" placeholder="全部类型" clearable style="width:130px" @change="load">
@@ -7,6 +10,7 @@
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<el-button plain @click="loadSurplus">余料</el-button>
<div style="flex:1" />
<el-upload :show-file-list="false" :http-request="doImport" accept=".xlsx,.xls">
<el-button type="warning" plain :loading="importing">导入 Excel</el-button>
@@ -39,6 +43,47 @@
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
</el-tab-pane>
<!-- 采购单 -->
<el-tab-pane label="采购单" name="purchase">
<div class="toolbar">
<el-select v-model="poStatus" placeholder="全部状态" clearable style="width:130px" @change="loadPurchases">
<el-option :value="0" label="待采购" /><el-option :value="1" label="已入库" /><el-option :value="2" label="已取消" />
</el-select>
<el-button type="primary" @click="loadPurchases">查询</el-button>
<div style="flex:1" />
<span class="tip">缺料转采购入口在「优化下料 → 下料方案」</span>
</div>
<el-table :data="purchases" v-loading="poLoading" stripe>
<el-table-column prop="po_no" label="采购单号" width="160" />
<el-table-column label="明细" min-width="240">
<template #default="{ row }">
<el-tag v-for="(it, i) in row.items" :key="i" size="small" style="margin:2px">
{{ it.name }} ×{{ it.qty }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="100" />
<el-table-column prop="created_at" label="创建时间" width="160" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="(['warning', 'success', 'info'] as const)[row.status]">
{{ ['待采购', '已入库', '已取消'][row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-popconfirm v-if="row.status === 0" title="按明细入库到库存?" confirm-button-text="入库"
@confirm="receivePo(row)">
<template #reference><el-button size="small" type="success" plain>采购入库</el-button></template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
:total="poTotal" :page-size="po_page_size" v-model:current-page="poPage" @current-change="loadPurchases" />
</el-tab-pane>
</el-tabs>
<el-dialog v-model="editVisible" :title="form.id ? '编辑材料' : '新增材料'" width="520px">
<el-form :model="form" label-width="80px">
@@ -115,6 +160,38 @@ async function move(row: any, direction: number) {
load()
}
// ---- 余料 / 采购单 ----
const tab = ref('stock')
const poStatus = ref<number | '' | undefined>(undefined)
const purchases = ref<any[]>([])
const poTotal = ref(0)
const poPage = ref(1)
const po_page_size = 15
const poLoading = ref(false)
async function loadSurplus() {
const d = await api<any>('get', `/api/warehouse/surplus?page=1&page_size=50`)
rows.value = d.data
total.value = d.total
ElMessage.info(`已筛选余料(共 ${d.total} 根)`)
}
async function loadPurchases() {
poLoading.value = true
try {
const st = poStatus.value === '' || poStatus.value === undefined ? '' : `&status=${poStatus.value}`
const d = await api<any>('get', `/api/purchases?page=${poPage.value}&page_size=${po_page_size}${st}`)
purchases.value = d.data
poTotal.value = d.total
} finally { poLoading.value = false }
}
async function receivePo(row: any) {
await api('post', `/api/purchases/${row.id}/receive`)
ElMessage.success('采购已入库')
loadPurchases()
}
// ---- Excel 导入导出 ----
const importing = ref(false)
@@ -129,7 +206,7 @@ async function doImport(opt: any) {
function exportExcel() { apiDownload('/api/warehouse/excel-out', 'warehouse.xlsx') }
onMounted(load)
onMounted(() => { load(); loadPurchases() })
</script>
<style scoped>