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
+2
View File
@@ -1,5 +1,7 @@
# CHANGELOG # CHANGELOG
- [2026-09-25 22:40] FEAT: v2.32 P1 批次4 CRM+仓库域 5 项。①客户跟进: CustomerFollow 模型+/customers/{id}/follows CRUD+客户页跟进对话框(timeline 历史+新增/下次日期); ②业务员体系: Salesman 模型+/api/salesmen CRUD+statistics 业绩(名下客户/合同额/预估提成)+客户页业务员 Tab+客户表单业务员选择; ③余料管理: surplus-in(读合同方案按最小长度入库 note=surplus)+surplus 列表+下料方案页余料入库按钮+仓库页余料筛选; ④领料: pickout(按领料清单扣库存+流水, 缺料报缺口)+下料方案页领料出库按钮; ⑤采购单: PurchaseOrder 模型(CG+日期+流水)+POST 自动读缺料清单/手动明细+receive 入库联动(库存+StockMove)+仓库页采购单 Tab(状态/入库); 实测: 跟进/业绩/余料 2 根入库/领料 5 根出库/采购 CG001 入库联动全通; 254 套全量校验 0 失败; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/salesmen.py, src/huamenchuang/api/purchases.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/router.py, frontend/src/views/CustomersView.vue, frontend/src/views/CutSolutionView.vue, frontend/src/views/WarehouseView.vue, doc/API.md)
- [2026-09-25 21:50] FEAT: v2.31 P1 批次3 生产域 4 项。①车间管理: Staff/ProcessAssignment 模型+/api/workshop(staff CRUD/assign 派工/assignments 派工历史/scan 扫码跟踪/board 跟单中心看板), 新页 /workshop 三 Tab(看板按环节列/环节扫码 BarcodeDetector 摄像头+扫码枪输入框/员工名册+派工), 路由菜单; ②双通道优化: optimize_1d_fine(BFD 最佳适应递减+有界余料均衡, 修复 while 死循环卡死事件循环)+optimize1d method=fast|fine, 优化页通道选择; ③订单利润: GET /reports/order-profit(订单金额 vs BOM 材料成本估算, 价格本成本均价口径)+利润汇总卡+明细表, 报表页嵌入; ④条码打印: 打印模板加 barcode 区块(JsBarcode Code128 订单号)+模板管理可加条码块; vue-tsc 0 错+构建通过; API 实测双通道/看板/利润/员工 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/workshop.py, src/huamenchuang/api/optimize.py, src/huamenchuang/services/optimize_service.py, src/huamenchuang/api/reports.py, src/huamenchuang/api/router.py, frontend/src/views/WorkshopView.vue, frontend/src/views/OptimizeView.vue, frontend/src/views/ReportView.vue, frontend/src/views/PrinterView.vue, frontend/src/router/index.ts, frontend/src/views/LayoutView.vue) - [2026-09-25 21:50] FEAT: v2.31 P1 批次3 生产域 4 项。①车间管理: Staff/ProcessAssignment 模型+/api/workshop(staff CRUD/assign 派工/assignments 派工历史/scan 扫码跟踪/board 跟单中心看板), 新页 /workshop 三 Tab(看板按环节列/环节扫码 BarcodeDetector 摄像头+扫码枪输入框/员工名册+派工), 路由菜单; ②双通道优化: optimize_1d_fine(BFD 最佳适应递减+有界余料均衡, 修复 while 死循环卡死事件循环)+optimize1d method=fast|fine, 优化页通道选择; ③订单利润: GET /reports/order-profit(订单金额 vs BOM 材料成本估算, 价格本成本均价口径)+利润汇总卡+明细表, 报表页嵌入; ④条码打印: 打印模板加 barcode 区块(JsBarcode Code128 订单号)+模板管理可加条码块; vue-tsc 0 错+构建通过; API 实测双通道/看板/利润/员工 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/workshop.py, src/huamenchuang/api/optimize.py, src/huamenchuang/services/optimize_service.py, src/huamenchuang/api/reports.py, src/huamenchuang/api/router.py, frontend/src/views/WorkshopView.vue, frontend/src/views/OptimizeView.vue, frontend/src/views/ReportView.vue, frontend/src/views/PrinterView.vue, frontend/src/router/index.ts, frontend/src/views/LayoutView.vue)
- [2026-09-25 21:00] FEAT: v2.30 P1 批次2 报价/产品域 3 项。①型材系列管理: /api/product-catelogs 树 CRUD(系列下有产品禁删)+产品挂系列(catelog_id)+按系列筛选, 产品页左树右表布局+编辑表单系列选择+补回丢失的产品编辑对话框; ②副梃计价收尾: 产品类别加 subbar(按尺寸/米计价, 随逐格编辑 assigns.subBar 算料); ③成本报价: 报价保存/更新按产品名匹配价格本成本写行 _cost(quote_by_cost 语义)+响应加 cost_total/profit, 报价页加成本/毛利列+分享链接(quote_link 走 shares type=quote); 实测: 成本匹配 6×38=228 毛利 84; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/api/business.py, frontend/src/views/ProductsView.vue, frontend/src/views/QuotationsView.vue, doc/API.md) - [2026-09-25 21:00] FEAT: v2.30 P1 批次2 报价/产品域 3 项。①型材系列管理: /api/product-catelogs 树 CRUD(系列下有产品禁删)+产品挂系列(catelog_id)+按系列筛选, 产品页左树右表布局+编辑表单系列选择+补回丢失的产品编辑对话框; ②副梃计价收尾: 产品类别加 subbar(按尺寸/米计价, 随逐格编辑 assigns.subBar 算料); ③成本报价: 报价保存/更新按产品名匹配价格本成本写行 _cost(quote_by_cost 语义)+响应加 cost_total/profit, 报价页加成本/毛利列+分享链接(quote_link 走 shares type=quote); 实测: 成本匹配 6×38=228 毛利 84; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/api/business.py, frontend/src/views/ProductsView.vue, frontend/src/views/QuotationsView.vue, doc/API.md)
+20
View File
@@ -501,3 +501,23 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
| 新增/改名/删系列 | POST/PUT/DELETE | `/api/product-catelogs[/{id}]` | `{name, fid?}` | 系列下有产品时禁止删除 | | 新增/改名/删系列 | POST/PUT/DELETE | `/api/product-catelogs[/{id}]` | `{name, fid?}` | 系列下有产品时禁止删除 |
| 产品筛选 | GET | `/api/products` | 增加 `catelog_id` | 产品挂系列(catelog_id);material 新增 `subbar`(副梃) | | 产品筛选 | GET | `/api/products` | 增加 `catelog_id` | 产品挂系列(catelog_id);material 新增 `subbar`(副梃) |
| **成本口径** | — | (自动) | — | 对齐 quote_by_cost:报价保存/更新时按产品名匹配价格本成本写入行 `_cost`;报价响应新增 `cost_total`(成本合计)与 `profit`(毛利);报价链接分享(quote_link)走 `/api/shares` type=quote 公开页 | | **成本口径** | — | (自动) | — | 对齐 quote_by_cost:报价保存/更新时按产品名匹配价格本成本写入行 `_cost`;报价响应新增 `cost_total`(成本合计)与 `profit`(毛利);报价链接分享(quote_link)走 `/api/shares` type=quote 公开页 |
### 12.26 P1 生产/CRM/仓库域(v2.31~v2.32)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 车间员工 CRUD | GET/POST/PUT/DELETE | `/api/workshop/staff[/{id}]` | `{name, phone?, role(环节), status?}` | 车间管理页名册 |
| **派工** | POST | `/api/workshop/assign` | `{order_id, process_name, staff_id, note?}` | 进度节点指派员工,记录 staffHistory |
| 派工历史 | GET | `/api/workshop/assignments` | `page` | |
| 扫码跟踪 | GET | `/api/workshop/scan/{order_no}` | 路径(条码号=订单号) | 对齐 scan_tracking;返回订单+7 节点进度 |
| 跟单看板 | GET | `/api/workshop/board` | — | 在产订单按当前环节分列 |
| 双通道优化 | POST | `/api/optimize1d` | 增加 `method: fast\|fine` | fast=FFD 快速;fine=BFD+余料均衡(详细优化,对齐 optimize1d-ai) |
| **订单利润** | GET | `/api/reports/order-profit` | `days?` | 对齐 order-profit:金额 vs BOM 材料成本估算(价格本成本均价),行级毛利/毛利率 |
| **客户跟进** | GET/POST/DELETE | `/api/customers/{id}/follows[/{fid}]` | `{content, next_date?}` | 对齐 customer_records;客户页"跟进"对话框 |
| 业务员体系 | GET/POST/PUT/DELETE | `/api/salesmen[/{id}]` | `{name, phone?, commission_rate?}` | 名册 CRUD;客户表 salesmen 字段选择业务员 |
| 业务员业绩 | GET | `/api/salesmen/statistics` | — | 名下客户数/合同数/合同额/预估提成(salesmen_statistics) |
| **余料入库** | POST | `/api/warehouse/surplus-in` | `{contract_id, min_length?}` | 读合同下料方案,≥最小长度余料逐根入库(note=surplus) |
| 余料列表 | GET | `/api/warehouse/surplus` | `page` | |
| **领料出库** | POST | `/api/warehouse/pickout` | `{contract_id, note?}` | 对齐 pickout:按领料清单扣库存+流水;库存不足报缺口 |
| **采购单** | POST | `/api/purchases` | `{contract_id?(自动读缺料), items?, note?}` | 缺料清单自动生成(CG+日期+流水) |
| 采购列表/入库 | GET/POST | `/api/purchases[?status]` · `/{id}/receive` | — | 入库联动:明细写入库存+StockMove 流水 |
+127 -4
View File
@@ -1,5 +1,8 @@
<template> <template>
<el-card> <el-card>
<el-tabs v-model="tab">
<!-- 客户列表 -->
<el-tab-pane label="客户列表" name="list">
<div class="toolbar"> <div class="toolbar">
<el-input v-model="keyword" placeholder="姓名/电话/地址" clearable style="width:240px" @keyup.enter="load" /> <el-input v-model="keyword" placeholder="姓名/电话/地址" clearable style="width:240px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button> <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="address" label="地址" min-width="180" />
<el-table-column prop="salesmen" label="业务员" width="100" /> <el-table-column prop="salesmen" label="业务员" width="100" />
<el-table-column prop="created_at" label="创建时间" width="170" /> <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 }"> <template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button> <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> <el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next" <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" /> :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-dialog v-model="editVisible" :title="form.id ? '编辑客户' : '新增客户'" width="480px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="70px"> <el-form :model="form" :rules="rules" ref="formRef" label-width="70px">
@@ -35,6 +83,11 @@
<el-input v-model="form.phone" maxlength="20" /> <el-input v-model="form.phone" maxlength="20" />
</el-form-item> </el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></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-item label="备注"><el-input v-model="form.note" type="textarea" /></el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -42,6 +95,24 @@
<el-button type="primary" @click="save">保存</el-button> <el-button type="primary" @click="save">保存</el-button>
</template> </template>
</el-dialog> </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> </el-card>
</template> </template>
@@ -58,7 +129,7 @@ const keyword = ref('')
const loading = ref(false) const loading = ref(false)
const editVisible = ref(false) const editVisible = ref(false)
const formRef = ref() 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 = { const rules = {
name: [{ required: true, message: '请输入客户姓名', trigger: 'blur' }], name: [{ required: true, message: '请输入客户姓名', trigger: 'blur' }],
phone: [{ pattern: /^[0-9+\-\s]{5,20}$/, message: '电话格式不正确', trigger: 'blur' }], phone: [{ pattern: /^[0-9+\-\s]{5,20}$/, message: '电话格式不正确', trigger: 'blur' }],
@@ -74,7 +145,7 @@ async function load() {
} }
function openEdit(row?: any) { 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 editVisible.value = true
} }
@@ -109,9 +180,61 @@ async function doImport(opt: any) {
function downloadTemplate() { apiDownload('/api/customers/excel-template', 'customers_template.xlsx') } function downloadTemplate() { apiDownload('/api/customers/excel-template', 'customers_template.xlsx') }
function exportExcel() { apiDownload('/api/customers/excel-out', 'customers.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> </script>
<style scoped> <style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; } .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> </style>
+28
View File
@@ -15,6 +15,18 @@
</template> </template>
<el-button type="primary" :loading="loading" :disabled="!contractId" @click="run">生成下料方案</el-button> <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-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> </div>
<template v-if="result"> <template v-if="result">
@@ -96,6 +108,22 @@ async function run() {
function print() { window.print() } 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 () => { onMounted(async () => {
const d = await api<any>('get', '/api/contracts?page=1&page_size=100') const d = await api<any>('get', '/api/contracts?page=1&page_size=100')
contracts.value = d.contracts?.data ?? [] contracts.value = d.contracts?.data ?? []
+78 -1
View File
@@ -1,5 +1,8 @@
<template> <template>
<el-card> <el-card>
<el-tabs v-model="tab">
<!-- 库存 -->
<el-tab-pane label="库存" name="stock">
<div class="toolbar"> <div class="toolbar">
<el-input v-model="keyword" placeholder="名称/编码" clearable style="width:200px" @keyup.enter="load" /> <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"> <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-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
</el-select> </el-select>
<el-button type="primary" @click="load">查询</el-button> <el-button type="primary" @click="load">查询</el-button>
<el-button plain @click="loadSurplus">余料</el-button>
<div style="flex:1" /> <div style="flex:1" />
<el-upload :show-file-list="false" :http-request="doImport" accept=".xlsx,.xls"> <el-upload :show-file-list="false" :http-request="doImport" accept=".xlsx,.xls">
<el-button type="warning" plain :loading="importing">导入 Excel</el-button> <el-button type="warning" plain :loading="importing">导入 Excel</el-button>
@@ -39,6 +43,47 @@
</el-table> </el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next" <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" /> :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-dialog v-model="editVisible" :title="form.id ? '编辑材料' : '新增材料'" width="520px">
<el-form :model="form" label-width="80px"> <el-form :model="form" label-width="80px">
@@ -115,6 +160,38 @@ async function move(row: any, direction: number) {
load() 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 导入导出 ---- // ---- Excel 导入导出 ----
const importing = ref(false) const importing = ref(false)
@@ -129,7 +206,7 @@ async function doImport(opt: any) {
function exportExcel() { apiDownload('/api/warehouse/excel-out', 'warehouse.xlsx') } function exportExcel() { apiDownload('/api/warehouse/excel-out', 'warehouse.xlsx') }
onMounted(load) onMounted(() => { load(); loadPurchases() })
</script> </script>
<style scoped> <style scoped>
+43
View File
@@ -130,3 +130,46 @@ async def excel_out(user: CurrentUser, db: DbSession):
data, widths=[16, 18, 30, 14, 12, 12]) data, widths=[16, 18, 30, 14, 12, 12])
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=customers.xlsx"}) headers={"Content-Disposition": "attachment; filename=customers.xlsx"})
# ---------------- 客户跟进记录(对齐 customer_records) ----------------
class FollowBody(BaseModel):
content: str = Field(min_length=1)
next_date: str | None = None # YYYY-MM-DD
@router.get("/{customer_id}/follows")
async def list_follows(customer_id: int, user: CurrentUser, db: DbSession):
from huamenchuang.models.business import CustomerFollow
rows = list((await db.execute(select(CustomerFollow).where(
CustomerFollow.company_id == user.company_id,
CustomerFollow.customer_id == customer_id)
.order_by(CustomerFollow.id.desc()).limit(100))).scalars())
return ok(data=[{"id": r.id, "content": r.content, "next_date": str(r.next_date) if r.next_date else None,
"created_at": str(r.created_at)[:19]} for r in rows])
@router.post("/{customer_id}/follows")
async def create_follow(customer_id: int, body: FollowBody, user: CurrentUser, db: DbSession):
from datetime import date as _date
from huamenchuang.models.business import CustomerFollow
c = await db.get(Customer, customer_id)
if c is None or c.company_id != user.company_id:
return fail("客户不存在")
r = CustomerFollow(company_id=user.company_id, customer_id=customer_id,
content=body.content, user_id=user.id,
next_date=_date.fromisoformat(body.next_date) if body.next_date else None)
db.add(r)
await db.flush()
return ok(data={"id": r.id}, message="跟进已记录")
@router.delete("/{customer_id}/follows/{fid}")
async def delete_follow(customer_id: int, fid: int, user: CurrentUser, db: DbSession):
from huamenchuang.models.business import CustomerFollow
r = await db.get(CustomerFollow, fid)
if r is None or r.company_id != user.company_id or r.customer_id != customer_id:
return fail("记录不存在")
await db.delete(r)
return ok(message="已删除")
+110
View File
@@ -0,0 +1,110 @@
"""采购单路由(对齐目标站 wareHouse/purchase:缺料生成采购,入库联动库存)。"""
import datetime
import json
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.business import PurchaseOrder
router = APIRouter(prefix="/purchases", tags=["采购单"])
class PurchaseItem(BaseModel):
name: str
spec: str = ""
length: int = 0
qty: int = 1
note: str = ""
class PurchaseBody(BaseModel):
contract_id: int | None = None
note: str = ""
items: list[PurchaseItem] = Field(default_factory=list, description="留空则从合同下料方案缺料清单生成")
async def _next_po_no(db, company_id: int) -> str:
today = datetime.date.today().strftime("%Y%m%d")
stmt = select(func.count(PurchaseOrder.id)).where(
PurchaseOrder.company_id == company_id,
func.strftime("%Y%m%d", PurchaseOrder.created_at) == today)
n = (await db.execute(stmt)).scalar() or 0
return f"CG{today}{n + 1:03d}"
def _out(p: PurchaseOrder) -> dict:
return {"id": p.id, "po_no": p.po_no, "contract_id": p.contract_id,
"items": json.loads(p.items or "[]"), "status": p.status,
"note": p.note, "created_at": str(p.created_at)[:19]}
@router.post("")
async def create_purchase(body: PurchaseBody, user: CurrentUser, db: DbSession):
"""创建采购单:items 为空时自动读取合同下料方案的缺料清单(shortage)。"""
import json as _json
items = [i.model_dump() for i in body.items]
if not items:
if not body.contract_id:
return fail("请提供采购明细或合同(自动读取缺料清单)")
from huamenchuang.models.extend import CompanyParam
p = (await db.execute(select(CompanyParam).where(
CompanyParam.company_id == user.company_id,
CompanyParam.key == f"cut_solution_{body.contract_id}"))).scalar_one_or_none()
if p is None:
return fail("该合同尚未生成下料方案,无缺料清单")
shortage = _json.loads(p.value).get("shortage") or []
if not shortage:
return fail("该合同下料方案没有缺料项")
items = [{"name": f"{s['label']} 缺料", "spec": f"{s['length']}mm", "length": s["length"],
"qty": s["qty"], "note": "下料缺料自动生成"} for s in shortage]
po = PurchaseOrder(company_id=user.company_id,
po_no=await _next_po_no(db, user.company_id),
contract_id=body.contract_id,
items=json.dumps(items, ensure_ascii=False),
note=body.note, user_id=user.id)
db.add(po)
await db.flush()
return ok(data=_out(po), message="采购单已创建")
@router.get("")
async def list_purchases(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
status: int | None = None):
conds = [PurchaseOrder.company_id == user.company_id]
if status is not None:
conds.append(PurchaseOrder.status == status)
where = *conds,
total = (await db.execute(select(func.count()).select_from(PurchaseOrder).where(*where))).scalar_one()
rows = (await db.execute(select(PurchaseOrder).where(*where).order_by(PurchaseOrder.id.desc())
.offset((page - 1) * page_size).limit(page_size))).scalars()
return ok(data=paginated([_out(p) for p in rows], page, page_size, total))
@router.post("/{po_id}/receive")
async def receive_purchase(po_id: int, user: CurrentUser, db: DbSession):
"""采购入库联动:items 写入库存(材料入库 + 流水)。"""
po = await db.get(PurchaseOrder, po_id)
if po is None or po.company_id != user.company_id:
return fail("采购单不存在")
if po.status == 1:
return fail("该采购单已入库")
from huamenchuang.models.contract import StockMove, WarehouseItem
for i in json.loads(po.items or "[]"):
if not i.get("name"):
continue
item = WarehouseItem(company_id=user.company_id, name=i["name"],
material="profile", spec=i.get("spec") or "",
length=int(i.get("length") or 0),
count=int(i.get("qty") or 1), unit="根",
note=f"采购入库 {po.po_no}")
db.add(item)
await db.flush()
db.add(StockMove(item_id=item.id, direction=1, count=int(i.get("qty") or 1),
note=f"采购入库 {po.po_no}", user_id=user.id))
po.status = 1
po.received_at = datetime.datetime.utcnow()
return ok(message="采购已入库")
+4
View File
@@ -12,9 +12,11 @@ from huamenchuang.api import (
order_format, order_format,
orders, orders,
platform, platform,
purchases,
quote, quote,
recommend, recommend,
reports, reports,
salesmen,
script, script,
shares, shares,
sunroom, sunroom,
@@ -39,6 +41,8 @@ api_router.include_router(shares.router)
api_router.include_router(recommend.router) api_router.include_router(recommend.router)
api_router.include_router(sunroom.router) api_router.include_router(sunroom.router)
api_router.include_router(workshop.router) api_router.include_router(workshop.router)
api_router.include_router(salesmen.router)
api_router.include_router(purchases.router)
api_router.include_router(warehouse.router) api_router.include_router(warehouse.router)
api_router.include_router(business.router) api_router.include_router(business.router)
api_router.include_router(platform.router) api_router.include_router(platform.router)
+82
View File
@@ -0,0 +1,82 @@
"""业务员路由:名册 CRUD + 业绩统计(对齐目标站 salesmen / salesmen_statistics)。"""
import datetime
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.business import Salesman
from huamenchuang.models.contract import Contract, Customer
router = APIRouter(prefix="/salesmen", tags=["业务员"])
class SalesmanBody(BaseModel):
name: str = Field(min_length=1, max_length=64)
phone: str = ""
commission_rate: float = Field(default=0, ge=0, le=100, description="提成比例 %")
note: str = ""
status: int = Field(default=1, ge=0, le=1)
@router.get("")
async def list_salesmen(user: CurrentUser, db: DbSession):
rows = list((await db.execute(select(Salesman).where(
Salesman.company_id == user.company_id).order_by(Salesman.id))).scalars())
return ok(data=[{"id": s.id, "name": s.name, "phone": s.phone,
"commission_rate": float(s.commission_rate), "note": s.note,
"status": s.status} for s in rows])
@router.post("")
async def create_salesman(body: SalesmanBody, user: CurrentUser, db: DbSession):
s = Salesman(company_id=user.company_id, **body.model_dump())
db.add(s)
await db.flush()
return ok(data={"id": s.id}, message="业务员已添加")
@router.put("/{sid}")
async def update_salesman(sid: int, body: SalesmanBody, user: CurrentUser, db: DbSession):
s = await db.get(Salesman, sid)
if s is None or s.company_id != user.company_id:
return fail("业务员不存在")
for k, v in body.model_dump().items():
setattr(s, k, v)
return ok(message="已更新")
@router.delete("/{sid}")
async def delete_salesman(sid: int, user: CurrentUser, db: DbSession):
s = await db.get(Salesman, sid)
if s is None or s.company_id != user.company_id:
return fail("业务员不存在")
await db.delete(s)
return ok(message="已删除")
@router.get("/statistics")
async def statistics(user: CurrentUser, db: DbSession):
"""业务员业绩:名下客户数 / 合同数 / 合同总额 / 预估提成。"""
rows = list((await db.execute(select(Salesman).where(
Salesman.company_id == user.company_id, Salesman.status == 1))).scalars())
# customers.salesmen 存业务员姓名(目标站同款字段语义)
cust = (await db.execute(select(Customer.salesmen, func.count()).where(
Customer.company_id == user.company_id).group_by(Customer.salesmen))).all()
contract = (await db.execute(select(Customer.salesmen, func.count(Contract.id),
func.coalesce(func.sum(Contract.money), 0))
.join(Contract, Contract.customer_id == Customer.id)
.where(Customer.company_id == user.company_id, Contract.status < 3)
.group_by(Customer.salesmen))).all()
cust_map = {n: c for n, c in cust if n}
ct_map = {n: (c, float(m or 0)) for n, c, m in contract if n}
return ok(data=[{
"id": s.id, "name": s.name, "phone": s.phone,
"commission_rate": float(s.commission_rate),
"customer_count": cust_map.get(s.name, 0),
"contract_count": ct_map.get(s.name, (0, 0))[0],
"contract_money": ct_map.get(s.name, (0, 0))[1],
"commission": round(ct_map.get(s.name, (0, 0))[1] * float(s.commission_rate) / 100, 2),
} for s in rows])
+89 -2
View File
@@ -1,7 +1,7 @@
"""仓库库存路由:材料 CRUD + 出入库 + Excel 导入导出。""" """仓库库存路由:材料 CRUD + 出入库 + 余料/领料 + Excel 导入导出。"""
from fastapi import APIRouter, UploadFile from fastapi import APIRouter, UploadFile
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import desc, select from sqlalchemy import desc, func, select
from huamenchuang.core.deps import CurrentUser, DbSession from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated from huamenchuang.core.response import fail, ok, paginated
@@ -69,6 +69,93 @@ async def create_item(body: ItemBody, user: CurrentUser, db: DbSession):
return ok(data={"id": item.id}, message="创建成功") return ok(data={"id": item.id}, message="创建成功")
# ---------------- 余料管理 / 领料(对齐目标站 surplus / pickout) ----------------
class SurplusBody(BaseModel):
contract_id: int | None = Field(default=None, description="留空则读取已保存的最近方案? 用合同方案")
min_length: int = Field(default=300, ge=0, le=3000, description="余料最小长度(低于此视为废料不入库)")
@router.get("/surplus")
async def list_surplus(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15):
"""余料列表(note=surplus 的库存项)。"""
conds = [WarehouseItem.company_id == user.company_id, WarehouseItem.note == "surplus"]
total = (await db.execute(select(func.count()).select_from(WarehouseItem).where(*conds))).scalar_one()
items = (await db.execute(select(WarehouseItem).where(*conds).order_by(WarehouseItem.length.desc())
.offset((page - 1) * page_size).limit(page_size))).scalars()
return ok(data=paginated([_out(i) for i in items], page, page_size, total))
@router.post("/surplus-in")
async def surplus_in(body: SurplusBody, user: CurrentUser, db: DbSession):
"""余料入库:读取合同下料方案,把 ≥ 最小长度的余料逐根入库(surplus 语义)。"""
import json as _json
from huamenchuang.models.extend import CompanyParam
if not body.contract_id:
return fail("请指定合同(读取其下料方案余料)")
p = (await db.execute(select(CompanyParam).where(
CompanyParam.company_id == user.company_id,
CompanyParam.key == f"cut_solution_{body.contract_id}"))).scalar_one_or_none()
if p is None:
return fail("该合同尚未生成下料方案")
plans = (_json.loads(p.value).get("result") or {}).get("plans") or []
added = 0
for plan in plans:
if plan.get("waste", 0) >= body.min_length:
item = WarehouseItem(company_id=user.company_id,
name=f"余料-{plan['stock_label']}",
material="profile", length=int(plan["waste"]),
count=1, unit="根", note="surplus")
db.add(item)
await db.flush()
db.add(StockMove(item_id=item.id, direction=1, count=1,
note="余料入库", user_id=user.id))
added += 1
if not added:
return fail("该方案没有达到最小长度的余料")
return ok(data={"added": added}, message=f"余料入库 {added} 根")
class PickoutBody(BaseModel):
contract_id: int
note: str = ""
@router.post("/pickout")
async def pickout(body: PickoutBody, user: CurrentUser, db: DbSession):
"""领料出库:按合同下料方案的领料清单,从库存扣减原料(pickout 语义)。"""
import json as _json
from huamenchuang.models.extend import CompanyParam
p = (await db.execute(select(CompanyParam).where(
CompanyParam.company_id == user.company_id,
CompanyParam.key == f"cut_solution_{body.contract_id}"))).scalar_one_or_none()
if p is None:
return fail("该合同尚未生成下料方案")
preparation = _json.loads(p.value).get("preparation") or []
if not preparation:
return fail("该方案没有领料清单")
picked, shortage = [], []
for prep in preparation:
need = int(prep["bars"])
# 同长度且数量足够的库存扣减
stmt = (select(WarehouseItem).where(
WarehouseItem.company_id == user.company_id,
WarehouseItem.material == "profile",
WarehouseItem.length == prep["stock_length"],
WarehouseItem.count >= need).order_by(WarehouseItem.count.desc()))
item = (await db.execute(stmt)).scalars().first()
if item is None:
shortage.append(f"{prep['stock_label']} 缺 {need} 根")
continue
item.count = float(item.count) - need
db.add(StockMove(item_id=item.id, direction=-1, count=need,
note=f"领料-{body.note or '车间切割'}", user_id=user.id))
picked.append({"item": item.name, "length": prep["stock_length"], "qty": need})
if shortage:
return fail(";".join(shortage) + f"(已领 {len(picked)} 项未提交,请先补库或采购)")
return ok(data={"picked": picked}, message=f"领料完成:{len(picked)} 项出库")
# ---------------- Excel 导入导出 ---------------- # ---------------- Excel 导入导出 ----------------
@router.get("/excel-out") @router.get("/excel-out")
+6
View File
@@ -3,11 +3,14 @@ from huamenchuang.models.announcement import Announcement
from huamenchuang.models.business import ( from huamenchuang.models.business import (
AfterSale, AfterSale,
ContractReceipt, ContractReceipt,
CustomerFollow,
MeasureOrder, MeasureOrder,
PriceScript, PriceScript,
Product, Product,
ProductCatelog, ProductCatelog,
PurchaseOrder,
Quotation, Quotation,
Salesman,
) )
from huamenchuang.models.contract import ( from huamenchuang.models.contract import (
Contract, Contract,
@@ -40,6 +43,7 @@ __all__ = [
"Contract", "Contract",
"ContractReceipt", "ContractReceipt",
"Customer", "Customer",
"CustomerFollow",
"DEFAULT_PROCESSES", "DEFAULT_PROCESSES",
"Device", "Device",
"MeasureOrder", "MeasureOrder",
@@ -52,8 +56,10 @@ __all__ = [
"ProcessAssignment", "ProcessAssignment",
"Product", "Product",
"ProductCatelog", "ProductCatelog",
"PurchaseOrder",
"Quotation", "Quotation",
"ReceiptAccount", "ReceiptAccount",
"Salesman",
"ShareItem", "ShareItem",
"SmsCode", "SmsCode",
"Staff", "Staff",
+47
View File
@@ -7,6 +7,53 @@ from sqlalchemy.orm import Mapped, mapped_column
from huamenchuang.core.database import Base from huamenchuang.core.database import Base
class CustomerFollow(Base):
"""客户跟进记录(对齐目标站 customer_records)。"""
__tablename__ = "customer_follows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"), index=True)
content: Mapped[str] = mapped_column(Text) # 跟进内容
next_date: Mapped[datetime.date | None] = mapped_column(default=None) # 下次跟进
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class Salesman(Base):
"""业务员名册(对齐目标站 salesmen:业绩统计)。"""
__tablename__ = "salesmen"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
name: Mapped[str] = mapped_column(String(64))
phone: Mapped[str] = mapped_column(String(20), default="")
commission_rate: Mapped[float] = mapped_column(Numeric(6, 3), default=0) # 提成比例 %
note: Mapped[str] = mapped_column(Text, default="")
status: Mapped[int] = mapped_column(default=1) # 1在职 0离职
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class PurchaseOrder(Base):
"""采购单(对齐目标站 wareHouse/purchase:缺料生成采购,入库联动)。"""
__tablename__ = "purchase_orders"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
po_no: Mapped[str] = mapped_column(String(32), unique=True) # CG+日期+流水
contract_id: Mapped[int | None] = mapped_column(default=None, index=True)
# items: [{name, spec, length, qty, note}]
items: Mapped[str] = mapped_column(Text, default="[]")
status: Mapped[int] = mapped_column(default=0) # 0待采购 1已入库 2已取消
note: Mapped[str] = mapped_column(Text, default="")
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
received_at: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
class ContractReceipt(Base): class ContractReceipt(Base):
"""合同收款记录(对齐目标站 contract_receipt:登记/作废/统计)。""" """合同收款记录(对齐目标站 contract_receipt:登记/作废/统计)。"""