feat: P1批次4 CRM+仓库域(客户跟进/业务员业绩/余料/领料/采购单)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# 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: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
@@ -501,3 +501,23 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
|
||||
| 新增/改名/删系列 | POST/PUT/DELETE | `/api/product-catelogs[/{id}]` | `{name, fid?}` | 系列下有产品时禁止删除 |
|
||||
| 产品筛选 | GET | `/api/products` | 增加 `catelog_id` | 产品挂系列(catelog_id);material 新增 `subbar`(副梃) |
|
||||
| **成本口径** | — | (自动) | — | 对齐 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 流水 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -130,3 +130,46 @@ async def excel_out(user: CurrentUser, db: DbSession):
|
||||
data, widths=[16, 18, 30, 14, 12, 12])
|
||||
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
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="已删除")
|
||||
|
||||
@@ -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="采购已入库")
|
||||
@@ -12,9 +12,11 @@ from huamenchuang.api import (
|
||||
order_format,
|
||||
orders,
|
||||
platform,
|
||||
purchases,
|
||||
quote,
|
||||
recommend,
|
||||
reports,
|
||||
salesmen,
|
||||
script,
|
||||
shares,
|
||||
sunroom,
|
||||
@@ -39,6 +41,8 @@ api_router.include_router(shares.router)
|
||||
api_router.include_router(recommend.router)
|
||||
api_router.include_router(sunroom.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(business.router)
|
||||
api_router.include_router(platform.router)
|
||||
|
||||
@@ -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])
|
||||
@@ -1,7 +1,7 @@
|
||||
"""仓库库存路由:材料 CRUD + 出入库 + Excel 导入导出。"""
|
||||
"""仓库库存路由:材料 CRUD + 出入库 + 余料/领料 + Excel 导入导出。"""
|
||||
from fastapi import APIRouter, UploadFile
|
||||
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.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="创建成功")
|
||||
|
||||
|
||||
# ---------------- 余料管理 / 领料(对齐目标站 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 导入导出 ----------------
|
||||
|
||||
@router.get("/excel-out")
|
||||
|
||||
@@ -3,11 +3,14 @@ from huamenchuang.models.announcement import Announcement
|
||||
from huamenchuang.models.business import (
|
||||
AfterSale,
|
||||
ContractReceipt,
|
||||
CustomerFollow,
|
||||
MeasureOrder,
|
||||
PriceScript,
|
||||
Product,
|
||||
ProductCatelog,
|
||||
PurchaseOrder,
|
||||
Quotation,
|
||||
Salesman,
|
||||
)
|
||||
from huamenchuang.models.contract import (
|
||||
Contract,
|
||||
@@ -40,6 +43,7 @@ __all__ = [
|
||||
"Contract",
|
||||
"ContractReceipt",
|
||||
"Customer",
|
||||
"CustomerFollow",
|
||||
"DEFAULT_PROCESSES",
|
||||
"Device",
|
||||
"MeasureOrder",
|
||||
@@ -52,8 +56,10 @@ __all__ = [
|
||||
"ProcessAssignment",
|
||||
"Product",
|
||||
"ProductCatelog",
|
||||
"PurchaseOrder",
|
||||
"Quotation",
|
||||
"ReceiptAccount",
|
||||
"Salesman",
|
||||
"ShareItem",
|
||||
"SmsCode",
|
||||
"Staff",
|
||||
|
||||
@@ -7,6 +7,53 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
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):
|
||||
"""合同收款记录(对齐目标站 contract_receipt:登记/作废/统计)。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user