feat: v0.3 报价算料引擎与仓库库存模块

后端:
- 报价算料: 从drawing_data几何提取型材清单(外框/中梃/扇框投影长度)
  与玻璃面积(鞋带公式), 套价格本计价, 支持样板/订单/自定义图纸三入口
- 仓库库存: 材料CRUD/出入库/流水/负库存拦截/预警线
- SQLite默认路径锚定项目根目录(修复cwd依赖)
前端:
- 仓库库存页(筛选/出入库对话框/预警高亮)
- 算料报价弹窗(样板库与订单详情入口, 支持打印)
- 路由与导航更新(8页面)
回归: 9个API端点全部200, 报价/优化/SPA路由验证通过
This commit is contained in:
HHY
2026-09-20 08:58:14 -07:00
parent f7a555dd6c
commit 065d387399
16 changed files with 630 additions and 8 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [2026-09-20 09:35] FEAT: v0.3 报价算料+仓库库存+前端扩展——报价引擎(从drawing_data几何提取外框/中梃/扇框型材清单+玻璃鞋带面积,套价格本计价);仓库模块(材料CRUD/出入库/流水/负库存拦截/预警);前端新增仓库页与报价弹窗(样板库/订单详情可唤起);SQLite路径锚定项目根修复cwd问题;全接口回归9/9通过 (Files: src/huamenchuang/**, frontend/**, doc/API.md)
- [2026-09-20 09:05] FEAT: 前后端全量开发 v0.2——后端新增客户/合同(意向单→正式)/优化下料(FFD)模块与订单合同快照;前端 Vue3+Element Plus 七页面(登录/工作台/订单/合同/客户/样板库/优化下料)与自研二维窗型画布渲染器(缩放/平移/开向标记/尺寸标注);构建产物挂载后端+SPA history 回退;全接口回归 6/6 通过 (Files: src/huamenchuang/**, frontend/**, README.md, doc/API.md) - [2026-09-20 09:05] FEAT: 前后端全量开发 v0.2——后端新增客户/合同(意向单→正式)/优化下料(FFD)模块与订单合同快照;前端 Vue3+Element Plus 七页面(登录/工作台/订单/合同/客户/样板库/优化下料)与自研二维窗型画布渲染器(缩放/平移/开向标记/尺寸标注);构建产物挂载后端+SPA history 回退;全接口回归 6/6 通过 (Files: src/huamenchuang/**, frontend/**, README.md, doc/API.md)
- [2026-09-20 08:14] FEAT: 订单模块 v0.1 实现与二维平面图全量采集——FastAPI 分层架构(api/services/repositories/models)、订单状态机(意向单→正式单→生产中→完工/取消)、7节点生产进度、审计日志、JWT认证、样板库API;创建契约对齐目标站{contract_id,drawing_data,pic_url};254张样板平面图全部下载成功并静态挂载;端到端冒烟测试7/7通过 (Files: src/huamenchuang/**, pyproject.toml, README.md, doc/API.md, data/windoor-images/*, data/fetch-images.py) - [2026-09-20 08:14] FEAT: 订单模块 v0.1 实现与二维平面图全量采集——FastAPI 分层架构(api/services/repositories/models)、订单状态机(意向单→正式单→生产中→完工/取消)、7节点生产进度、审计日志、JWT认证、样板库API;创建契约对齐目标站{contract_id,drawing_data,pic_url};254张样板平面图全部下载成功并静态挂载;端到端冒烟测试7/7通过 (Files: src/huamenchuang/**, pyproject.toml, README.md, doc/API.md, data/windoor-images/*, data/fetch-images.py)
- [2026-09-20 07:18] FEAT: 整站全量采集——GET 扫描 279 个接口全部留档(业务成功76)、POST 安全探测 24 个(付费墙/参数契约确认)、订单写入接口契约经源码静态分析补全(orders CRUD + 创建订单请求体);doc/API.md 新增 §11 采集结果与付费墙语义 (Files: data/full-site/*, doc/API.md, doc/克隆实施方案.md) - [2026-09-20 07:18] FEAT: 整站全量采集——GET 扫描 279 个接口全部留档(业务成功76)、POST 安全探测 24 个(付费墙/参数契约确认)、订单写入接口契约经源码静态分析补全(orders CRUD + 创建订单请求体);doc/API.md 新增 §11 采集结果与付费墙语义 (Files: data/full-site/*, doc/API.md, doc/克隆实施方案.md)
+22 -4
View File
@@ -258,21 +258,39 @@
|---|---|---|---|---| |---|---|---|---|---|
| 一维优化 | POST | `/api/optimize1d` | `{demands:[{length,qty,label}], stocks:[{length,qty,label}], kerf:5}` | FFD 首递减装箱;返回 `plans[]`(每根原料的切割组合+余料)、`unplaced[]`、`stats.utilization`;需求>库存时 code=0 | | 一维优化 | POST | `/api/optimize1d` | `{demands:[{length,qty,label}], stocks:[{length,qty,label}], kerf:5}` | FFD 首递减装箱;返回 `plans[]`(每根原料的切割组合+余料)、`unplaced[]`、`stats.utilization`;需求>库存时 code=0 |
### 12.8 前端(v0.2,Vue3 + Element Plus,构建产物挂载于后端) ### 12.8 报价算料 `/api/offer-price`(v0.3 新增)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 算料报价 | POST | `/api/offer-price` | `{drawing_data \| template_id \| order_id(三选一), prices?}` | 从 drawing_data 几何提取:外框/中梃/扇框型材清单(工程近似)+ 玻璃面积(鞋带公式)+ 五金,套价格本计价。默认价: 型材40-50元/米、玻璃120元/㎡、五金180元/扇;`prices` 可覆盖 |
### 12.9 仓库库存 `/api/warehouse`(v0.3 新增)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 库存列表 | GET | `/api/warehouse/list?page=1&keyword=&material=` | query | material: profile/glass/hardware/addon |
| 新增材料 | POST | `/api/warehouse` | `{name(必填), material, spec, color, unit, price, count, warning, length}` | |
| 编辑材料 | PUT | `/api/warehouse/{id}` | 同新增 | |
| **出入库** | POST | `/api/warehouse/move` | `{item_id, direction: 1\|-1, count, note}` | 出库拒绝负库存,写流水 |
| 出入库流水 | GET | `/api/warehouse/moves?page=1` | query | 全部流水倒序 |
### 12.10 前端(v0.3,Vue3 + Element Plus,构建产物挂载于后端)
| 路由 | 页面 | 功能 | | 路由 | 页面 | 功能 |
|---|---|---| |---|---|---|
| `/login` | 登录 | JWT 登录 | | `/login` | 登录 | JWT 登录 |
| `/dashboard` | 工作台 | 订单/意向单/客户/合同统计 + 快捷入口 | | `/dashboard` | 工作台 | 订单/意向单/客户/合同统计 + 快捷入口 |
| `/orders` | 订单中心 | 搜索/新建(选合同+样板自动带图纸)/详情抽屉(**画布渲染 drawing_data**)+状态机操作+生产进度更新 | | `/orders` | 订单中心 | 搜索/新建(选合同+样板自动带图纸)/详情抽屉(**画布渲染 drawing_data**)+状态机操作+生产进度更新+算料报价+打印 |
| `/contracts` | 合同管理 | 列表/新建(选客户带出信息)/意向单转正式/一键下订单 | | `/contracts` | 合同管理 | 列表/新建(选客户带出信息)/意向单转正式/一键下订单 |
| `/customers` | 客户管理 | CRUD + 搜索 | | `/customers` | 客户管理 | CRUD + 搜索 |
| `/templates` | 样板库 | 254 套样板网格(平面图) + 分类筛选 + 点击预览(**画布渲染**) + 用此样板下单 | | `/templates` | 样板库 | 254 套样板网格(平面图) + 分类筛选 + 画布预览 + 算料报价 + 用此样板下单 |
| `/warehouse` | 仓库库存 | 材料 CRUD + 出入库(流水) + 库存预警高亮 |
| `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) | | `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) |
**画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 扇开向三角标记(od 角度) → 总宽高尺寸标注;支持滚轮缩放、拖拽平移、双击复位。 **画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 扇开向三角标记(od 角度) → 总宽高尺寸标注;支持滚轮缩放、拖拽平移、双击复位。
**报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。
### 12.9 种子数据 ### 12.11 种子数据
```bash ```bash
uv sync # 安装依赖 uv sync # 安装依赖
+64
View File
@@ -0,0 +1,64 @@
<template>
<el-dialog v-model="visible" title="算料报价" width="640px">
<div v-if="result" v-loading="loading">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="窗型尺寸">{{ result.width_mm }} × {{ result.height_mm }} mm</el-descriptions-item>
<el-descriptions-item label="玻璃面积">{{ result.glass_area_m2 }} ㎡</el-descriptions-item>
<el-descriptions-item label="总价">
<span class="total">{{ result.total }} 元</span>
</el-descriptions-item>
</el-descriptions>
<el-table :data="result.rows" size="small" border style="margin-top:10px">
<el-table-column prop="note" label="项目" min-width="110">
<template #default="{ row }">{{ row.note || kindLabel(row.kind) }}</template>
</el-table-column>
<el-table-column label="规格" width="110">
<template #default="{ row }">{{ row.length ? row.length + 'mm' : '-' }}</template>
</el-table-column>
<el-table-column prop="qty" label="数量" width="80" />
<el-table-column label="单价" width="90">
<template #default="{ row }">{{ row.unit_price }} {{ row.unit }}</template>
</el-table-column>
<el-table-column prop="amount" label="金额" width="100" />
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-button type="primary" @click="print">打印报价单</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { api } from '../api'
const visible = ref(false)
const loading = ref(false)
const result = ref<any>(null)
let source: { drawing_data?: string; template_id?: number; order_id?: number } = {}
function kindLabel(k: string) {
return ({ frame: '外框', mullion: '中梃', sash: '扇框', glass: '玻璃', hardware: '五金' } as Record<string, string>)[k] ?? k
}
async function open(src: typeof source) {
source = src
visible.value = true
loading.value = true
try {
result.value = await api<any>('post', '/api/offer-price', src)
} finally { loading.value = false }
}
function print() {
window.print()
}
defineExpose({ open })
</script>
<style scoped>
.total { color: #e6483d; font-size: 18px; font-weight: 700; }
@media print { .el-dialog__footer { display: none; } }
</style>
+1
View File
@@ -16,6 +16,7 @@ const router = createRouter({
{ path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } }, { path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } },
{ path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } }, { path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } },
{ path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } }, { path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } },
{ path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } },
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } }, { path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
], ],
}, },
+1
View File
@@ -9,6 +9,7 @@
<el-menu-item index="/contracts"><el-icon><Document /></el-icon>合同管理</el-menu-item> <el-menu-item index="/contracts"><el-icon><Document /></el-icon>合同管理</el-menu-item>
<el-menu-item index="/customers"><el-icon><User /></el-icon>客户管理</el-menu-item> <el-menu-item index="/customers"><el-icon><User /></el-icon>客户管理</el-menu-item>
<el-menu-item index="/templates"><el-icon><Grid /></el-icon>样板库</el-menu-item> <el-menu-item index="/templates"><el-icon><Grid /></el-icon>样板库</el-menu-item>
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item> <el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
+7
View File
@@ -72,6 +72,8 @@
<span>客户:{{ detail.customer_name || '-' }}</span> <span>客户:{{ detail.customer_name || '-' }}</span>
<span>金额:{{ detail.money }}</span> <span>金额:{{ detail.money }}</span>
<span>面积:{{ detail.area }}㎡</span> <span>面积:{{ detail.area }}㎡</span>
<el-button size="small" type="success" plain @click="quoteRef.open({ order_id: detail.id })">算料报价</el-button>
<el-button size="small" @click="printOrder">打印订单</el-button>
</div> </div>
<div class="canvas-box"> <div class="canvas-box">
<WindoorCanvas :drawing-data="detail.drawing_data" /> <WindoorCanvas :drawing-data="detail.drawing_data" />
@@ -92,6 +94,7 @@
</div> </div>
</div> </div>
</el-drawer> </el-drawer>
<QuoteDialog ref="quoteRef" />
</div> </div>
</template> </template>
@@ -101,8 +104,12 @@ import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api' import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue' import WindoorCanvas from '../components/WindoorCanvas.vue'
import QuoteDialog from '../components/QuoteDialog.vue'
const route = useRoute() const route = useRoute()
const quoteRef = ref()
function printOrder() { window.print() }
const statusLabels: Record<number, string> = { 0: '意向单', 1: '正式单', 2: '生产中', 3: '已完工', 4: '已取消' } const statusLabels: Record<number, string> = { 0: '意向单', 1: '正式单', 2: '生产中', 3: '已完工', 4: '已取消' }
+4
View File
@@ -28,10 +28,12 @@
<div class="meta"> <div class="meta">
<span>尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm</span> <span>尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm</span>
<span>引用次数: {{ current.times }}</span> <span>引用次数: {{ current.times }}</span>
<el-button type="success" size="small" @click="quoteRef.open({ template_id: current.id })">算料报价</el-button>
<el-button type="primary" size="small" @click="useInOrder">用此样板下单</el-button> <el-button type="primary" size="small" @click="useInOrder">用此样板下单</el-button>
</div> </div>
</div> </div>
</el-dialog> </el-dialog>
<QuoteDialog ref="quoteRef" />
</div> </div>
</template> </template>
@@ -40,8 +42,10 @@ import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { api } from '../api' import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue' import WindoorCanvas from '../components/WindoorCanvas.vue'
import QuoteDialog from '../components/QuoteDialog.vue'
const router = useRouter() const router = useRouter()
const quoteRef = ref()
const rows = ref<any[]>([]) const rows = ref<any[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
+120
View File
@@ -0,0 +1,120 @@
<template>
<el-card>
<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">
<el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" />
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<div style="flex:1" />
<el-button type="primary" plain @click="openEdit()">新增材料</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="bom_code" label="编码" width="90" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="类型" width="80">
<template #default="{ row }">{{ materialLabel(row.material) }}</template>
</el-table-column>
<el-table-column prop="spec" label="规格" width="110" />
<el-table-column prop="color" label="颜色" width="90" />
<el-table-column label="库存" width="110">
<template #default="{ row }">
<span :class="{ low: row.warning > 0 && row.count <= row.warning }">{{ row.count }}</span>
{{ row.unit }}
</template>
</el-table-column>
<el-table-column prop="price" label="单价" width="90" />
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button size="small" type="success" plain @click="move(row, 1)">入</el-button>
<el-button size="small" type="warning" plain @click="move(row, -1)">出</el-button>
<el-button size="small" @click="openEdit(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-dialog v-model="editVisible" :title="form.id ? '编辑材料' : '新增材料'" width="520px">
<el-form :model="form" label-width="80px">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="类型">
<el-select v-model="form.material" style="width:100%">
<el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" />
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
</el-select>
</el-form-item>
<el-form-item label="规格"><el-input v-model="form.spec" /></el-form-item>
<el-form-item label="颜色"><el-input v-model="form.color" /></el-form-item>
<el-form-item label="单位"><el-input v-model="form.unit" style="width:100px" /></el-form-item>
<el-form-item label="单价"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
<el-form-item label="库存"><el-input-number v-model="form.count" :min="0" /></el-form-item>
<el-form-item label="预警线"><el-input-number v-model="form.warning" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const page_size = 15
const keyword = ref('')
const material = ref<string | undefined>()
const loading = ref(false)
const editVisible = ref(false)
const form = reactive<any>({ id: 0, name: '', material: 'profile', spec: '', color: '', unit: '根', price: 0, count: 0, warning: 0 })
function materialLabel(m: string) {
return ({ profile: '型材', glass: '玻璃', hardware: '五金', addon: '配件' } as Record<string, string>)[m] ?? m
}
async function load() {
loading.value = true
try {
const qs = `page=${page.value}&page_size=${page_size}&keyword=${keyword.value}${material.value ? `&material=${material.value}` : ''}`
const d = await api<any>('get', `/api/warehouse/list?${qs}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
function openEdit(row?: any) {
Object.assign(form, row ?? { id: 0, name: '', material: 'profile', spec: '', color: '', unit: '根', price: 0, count: 0, warning: 0 })
editVisible.value = true
}
async function save() {
if (form.id) await api('put', `/api/warehouse/${form.id}`, form)
else await api('post', '/api/warehouse', form)
ElMessage.success('已保存')
editVisible.value = false
load()
}
async function move(row: any, direction: number) {
const { value } = await ElMessageBox.prompt(
`${row.name} ${direction === 1 ? '入库' : '出库'}数量(${row.unit})`, '出入库',
{ inputValue: '1', inputPattern: /^\d+(\.\d+)?$/, inputErrorMessage: '请输入数字' })
await api('post', '/api/warehouse/move', { item_id: row.id, direction, count: Number(value), note: '页面操作' })
ElMessage.success('操作成功')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.low { color: #f56c6c; font-weight: 700; }
</style>
+45
View File
@@ -0,0 +1,45 @@
"""报价路由:从 drawing_data 算料计价(对齐目标站 offer-price 语义,自研实现)。"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from huamenchuang.core.deps import CurrentUser, DbSession, require_module
from huamenchuang.core.response import fail, ok
from huamenchuang.services.quote_service import quote as calc_quote
router = APIRouter(prefix="/offer-price", tags=["报价"],
dependencies=[Depends(require_module("quote"))])
class QuoteRequest(BaseModel):
drawing_data: str | dict | None = Field(default=None, description="画布 drawing_data(与 template_id/order_id 三选一)")
template_id: int | None = Field(default=None, description="或直接指定样板")
order_id: int | None = Field(default=None, description="或对已有订单报价")
prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}")
async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None:
if body.drawing_data:
return body.drawing_data
if body.template_id:
from sqlalchemy import select
from huamenchuang.models.windoor_template import WindoorTemplate
t = (await db.execute(select(WindoorTemplate).where(WindoorTemplate.id == body.template_id))).scalar_one_or_none()
return t.drawing_data if t else None
if body.order_id:
from huamenchuang.models.order import Order
o = await db.get(Order, body.order_id)
return o.drawing_data if o else None
return None
@router.post("")
async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession):
"""算料报价:型材清单 + 玻璃面积 + 五金 + 总价。"""
drawing = await _resolve_drawing(db, body)
if drawing is None:
return fail("未找到图纸数据")
try:
result = calc_quote(drawing, body.prices)
except Exception:
return fail("图纸数据解析失败")
return ok(data=result, message="报价成功")
+12 -1
View File
@@ -1,7 +1,16 @@
"""路由汇总。""" """路由汇总。"""
from fastapi import APIRouter from fastapi import APIRouter
from huamenchuang.api import auth, contracts, customers, optimize, orders, windoor from huamenchuang.api import (
auth,
contracts,
customers,
optimize,
orders,
quote,
warehouse,
windoor,
)
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router) api_router.include_router(auth.router)
@@ -10,3 +19,5 @@ api_router.include_router(customers.router)
api_router.include_router(contracts.router) api_router.include_router(contracts.router)
api_router.include_router(windoor.router) api_router.include_router(windoor.router)
api_router.include_router(optimize.router) api_router.include_router(optimize.router)
api_router.include_router(quote.router)
api_router.include_router(warehouse.router)
+109
View File
@@ -0,0 +1,109 @@
"""仓库库存路由:材料 CRUD + 出入库。"""
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import desc, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.contract import StockMove, WarehouseItem
router = APIRouter(prefix="/warehouse", tags=["仓库"])
class ItemBody(BaseModel):
name: str = Field(min_length=1, max_length=128)
bom_code: str = ""
material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon)$")
color: str = ""
spec: str = ""
unit: str = "根"
cost: float = 0
price: float = 0
count: float = 0
warning: float = 0
length: int = 0
note: str = ""
class MoveBody(BaseModel):
item_id: int
direction: int = Field(default=1, ge=-1, le=1) # 1入库 -1出库
count: float = Field(gt=0)
note: str = ""
def _out(i: WarehouseItem) -> dict:
return {
"id": i.id, "bom_code": i.bom_code, "name": i.name, "material": i.material,
"color": i.color, "spec": i.spec, "unit": i.unit, "cost": float(i.cost),
"price": float(i.price), "count": float(i.count), "warning": float(i.warning),
"length": i.length, "note": i.note, "updated_at": str(i.updated_at),
}
@router.get("/list")
async def list_items(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
keyword: str = "", material: str | None = None):
"""材料库存列表(分页 + 筛选,对齐目标站 warehouse_list 语义)。"""
from sqlalchemy import func, or_
conds = [WarehouseItem.company_id == user.company_id]
if material:
conds.append(WarehouseItem.material == material)
if kw := keyword.strip():
like = f"%{kw}%"
conds.append(or_(WarehouseItem.name.like(like), WarehouseItem.bom_code.like(like)))
where = *conds,
total = (await db.execute(select(func.count()).select_from(WarehouseItem).where(*where))).scalar_one()
stmt = (select(WarehouseItem).where(*where).order_by(WarehouseItem.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([_out(i) for i in items], page, page_size, total))
@router.post("")
async def create_item(body: ItemBody, user: CurrentUser, db: DbSession):
"""新增材料。"""
item = WarehouseItem(company_id=user.company_id, **body.model_dump())
db.add(item)
await db.flush()
return ok(data={"id": item.id}, message="创建成功")
@router.put("/{item_id}")
async def update_item(item_id: int, body: ItemBody, user: CurrentUser, db: DbSession):
"""编辑材料。"""
item = await db.get(WarehouseItem, item_id)
if item is None or item.company_id != user.company_id:
return fail("材料不存在")
for k, v in body.model_dump().items():
setattr(item, k, v)
return ok(message="更新成功")
@router.post("/move")
async def stock_move(body: MoveBody, user: CurrentUser, db: DbSession):
"""出入库:direction=1 入库 / -1 出库(出库不允许负库存)。"""
item = await db.get(WarehouseItem, body.item_id)
if item is None or item.company_id != user.company_id:
return fail("材料不存在")
delta = body.count if body.direction == 1 else -body.count
if float(item.count) + delta < 0:
return fail(f"库存不足: 当前 {float(item.count)}{item.unit}")
item.count = float(item.count) + delta
db.add(StockMove(item_id=item.id, direction=body.direction, count=body.count,
note=body.note, user_id=user.id))
return ok(data={"count": float(item.count)}, message="操作成功")
@router.get("/moves")
async def move_logs(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 20):
"""出入库流水。"""
total_q = select(func.count()).select_from(StockMove)
total = (await db.execute(total_q)).scalar_one()
stmt = (select(StockMove).order_by(desc(StockMove.id))
.offset((page - 1) * page_size).limit(page_size))
moves = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"id": m.id, "item_id": m.item_id, "direction": m.direction, "count": float(m.count),
"note": m.note, "created_at": str(m.created_at),
} for m in moves], page, page_size, total))
+7 -2
View File
@@ -1,11 +1,16 @@
"""全局配置。开发默认 SQLite,生产可通过环境变量切 MySQL。""" """全局配置。开发默认 SQLite(锚定项目根,避免 cwd 影响),生产可通过环境变量切 MySQL。"""
from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
_DEFAULT_DB = f"sqlite+aiosqlite:///{(_PROJECT_ROOT / 'huamenchuang.db').as_posix()}"
class Settings(BaseSettings): class Settings(BaseSettings):
app_name: str = "huamenchuang" app_name: str = "huamenchuang"
# 开发默认 SQLite;生产示例: mysql+aiomysql://user:pwd@localhost/hmc # 开发默认 SQLite;生产示例: mysql+aiomysql://user:pwd@localhost/hmc
database_url: str = "sqlite+aiosqlite:///./huamenchuang.db" database_url: str = _DEFAULT_DB
jwt_secret: str = "change-me-in-production" jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 24 jwt_expire_hours: int = 24
+23
View File
@@ -0,0 +1,23 @@
"""drawing_data 的 Python 侧解析(与前端 types/drawing.ts 同构的最小模型)。"""
import json
from typing import Any, TypedDict
class FrameShape(TypedDict, total=False):
type: str
polygon: dict
fm: dict
mm: dict
cm: dict
sm: list
class DrawingData(TypedDict, total=False):
s: dict
sm: list
def parse_drawing(raw: str | dict) -> DrawingData:
"""解析 drawing_data(兼容 JSON 字符串或对象)。"""
dd = json.loads(raw) if isinstance(raw, str) else raw
return dd # type: ignore[return-value]
+10 -1
View File
@@ -1,5 +1,11 @@
"""ORM 模型统一导出。""" """ORM 模型统一导出。"""
from huamenchuang.models.contract import Contract, Customer from huamenchuang.models.contract import (
Contract,
Customer,
ReceiptAccount,
StockMove,
WarehouseItem,
)
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
from huamenchuang.models.user import User from huamenchuang.models.user import User
from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate
@@ -11,7 +17,10 @@ __all__ = [
"Order", "Order",
"OrderLog", "OrderLog",
"OrderProcess", "OrderProcess",
"ReceiptAccount",
"StockMove",
"User", "User",
"WarehouseItem",
"WindoorCatelog", "WindoorCatelog",
"WindoorTemplate", "WindoorTemplate",
] ]
+57
View File
@@ -25,6 +25,63 @@ class Customer(Base):
) )
class ReceiptAccount(Base):
"""收款账户。"""
__tablename__ = "receipt_accounts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
type: Mapped[int] = mapped_column(default=1) # 1银行 2现金 3其他
name: Mapped[str] = mapped_column(String(64))
number: Mapped[str] = mapped_column(String(64), default="")
bank: Mapped[str] = mapped_column(String(64), default="")
note: Mapped[str] = mapped_column(Text, default="")
is_default: Mapped[int] = mapped_column(default=0)
balance: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class WarehouseItem(Base):
"""材料库存(型材/玻璃/五金/配件)。"""
__tablename__ = "warehouse_items"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
bom_code: Mapped[str] = mapped_column(String(32), default="")
name: Mapped[str] = mapped_column(String(128))
# profile型材/glass玻璃/hardware五金/addon配件
material: Mapped[str] = mapped_column(String(16), default="profile", index=True)
color: Mapped[str] = mapped_column(String(32), default="")
spec: Mapped[str] = mapped_column(String(64), default="") # 规格型号
unit: Mapped[str] = mapped_column(String(8), default="根")
cost: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
price: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
count: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
warning: Mapped[float] = mapped_column(Numeric(12, 2), default=0) # 库存预警线
length: Mapped[int] = mapped_column(default=0) # 单根长度mm(型材)
note: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)
class StockMove(Base):
"""出入库流水。direction: 1入库 -1出库"""
__tablename__ = "stock_moves"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
item_id: Mapped[int] = mapped_column(ForeignKey("warehouse_items.id"), index=True)
direction: Mapped[int] = mapped_column(default=1)
count: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
note: Mapped[str] = mapped_column(String(255), default="")
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class Contract(Base): class Contract(Base):
"""合同/意向单:目标站契约 status="意向单" 起步,oids[] 记录关联订单。""" """合同/意向单:目标站契约 status="意向单" 起步,oids[] 记录关联订单。"""
+147
View File
@@ -0,0 +1,147 @@
"""报价算料服务:从 drawing_data 几何信息提取型材下料清单与玻璃面积,套价格本计价。
提取规则(自研 v1,工程近似):
- 外框:每 Frame 形状 2 根宽 + 2 根高(忽略 45° 拼角损耗)
- 中梃:每条分割线按其在框内投影长度计(竖梃=框高,横梃=框宽)
- 扇框:每个 Sash 4 根(2×扇宽 + 2×扇高)
- 玻璃:全部玻璃区域面积之和(鞋带公式)
"""
from dataclasses import dataclass, field
from huamenchuang.core.drawing import DrawingData, parse_drawing
@dataclass
class BarItem:
"""型材清单行。"""
kind: str # frame/mullion/sash
length: int # mm
qty: int = 1
note: str = ""
@dataclass
class TakeoffResult:
bars: list[BarItem] = field(default_factory=list)
glass_area_m2: float = 0.0
width_mm: int = 0
height_mm: int = 0
def _shoelace_area(pts: list[tuple[float, float]]) -> float:
"""多边形面积(鞋带公式)。"""
n = len(pts)
s = 0.0
for i in range(n):
x1, y1 = pts[i]
x2, y2 = pts[(i + 1) % n]
s += x1 * y2 - x2 * y1
return abs(s) / 2
def _region_bbox(region_pts):
xs = [p[0] for p in region_pts]
ys = [p[1] for p in region_pts]
return min(xs), min(ys), max(xs), max(ys)
def takeoff(dd: DrawingData) -> TakeoffResult:
"""从解析后的 drawing_data 提取算料清单。"""
result = TakeoffResult()
xs, ys = [], []
for shape in dd.get("sm", []):
regions = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r]
for r in ((shape.get("polygon") or {}).get("polygon") or [])]
if not regions:
continue
# 形状外框 bbox
all_pts = [p for r in regions for p in r]
x0, y0, x1, y1 = _region_bbox(all_pts)
w, h = round(x1 - x0), round(y1 - y0)
xs.extend([x0, x1])
ys.extend([y0, y1])
result.bars.append(BarItem("frame", w, 2, "外框-宽"))
result.bars.append(BarItem("frame", h, 2, "外框-高"))
# 中梃:投影长度(竖梃=框高,横梃=框宽)
for sp in ((shape.get("mm") or {}).get("sp") or []):
norm = sp["line"]["norm"]
if abs(norm["x"]) >= abs(norm["y"]):
result.bars.append(BarItem("mullion", h, 1, "竖梃"))
else:
result.bars.append(BarItem("mullion", w, 1, "横梃"))
# 玻璃面积
result.glass_area_m2 += sum(_shoelace_area(r) for r in regions) / 1_000_000
# 扇框
for sub in (shape.get("sm") or []):
bd = sub.get("bd") if isinstance(sub, dict) else None
if not bd or bd.get("hidden"):
continue
bw = round(abs(bd["to"]["x"] - bd["from"]["x"]))
bh = round(abs(bd["to"]["y"] - bd["from"]["y"]))
if bw < 10 or bh < 10:
continue
result.bars.append(BarItem("sash", bw, 2, "扇框-宽"))
result.bars.append(BarItem("sash", bh, 2, "扇框-高"))
if xs:
result.width_mm = round(max(xs) - min(xs))
result.height_mm = round(max(ys) - min(ys))
return result
def merge_bars(bars: list[BarItem]) -> list[dict]:
"""同长度同类合并。"""
merged: dict[tuple, dict] = {}
for b in bars:
key = (b.kind, b.length, b.note)
if key in merged:
merged[key]["qty"] += b.qty
else:
merged[key] = {"kind": b.kind, "length": b.length, "note": b.note, "qty": b.qty}
return sorted(merged.values(), key=lambda x: -x["length"])
def quote(drawing: str | dict, prices: dict | None = None) -> dict:
"""完整报价:算料 + 套价。
prices 结构: {frame: 元/米, mullion: 元/米, sash: 元/米, glass: 元/㎡, hardware: 元/扇}
"""
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180}
if prices:
p.update(prices)
dd = parse_drawing(drawing)
t = takeoff(dd)
rows, total = [], 0.0
unit_kind = {"frame": "frame", "mullion": "mullion", "sash": "sash"}
for b in merge_bars(t.bars):
amount = b["length"] * b["qty"] / 1000 * p[unit_kind[b["kind"]]]
total += amount
rows.append({**b, "unit_price": p[unit_kind[b["kind"]]], "unit": "元/米", "amount": round(amount, 2)})
glass_amount = t.glass_area_m2 * p["glass"]
total += glass_amount
rows.append({"kind": "glass", "length": 0, "note": "玻璃", "qty": round(t.glass_area_m2, 3),
"unit_price": p["glass"], "unit": "元/㎡", "amount": round(glass_amount, 2)})
sash_count = sum(
1 for s in dd.get("sm", []) for sub in (s.get("sm") or [])
if isinstance(sub, dict) and sub.get("bd") and not sub.get("bd").get("hidden")
)
hw_amount = sash_count * p["hardware"]
if sash_count:
rows.append({"kind": "hardware", "length": 0, "note": "五金", "qty": sash_count,
"unit_price": p["hardware"], "unit": "元/扇", "amount": round(hw_amount, 2)})
total += hw_amount
return {
"rows": rows,
"glass_area_m2": round(t.glass_area_m2, 3),
"width_mm": t.width_mm,
"height_mm": t.height_mm,
"total": round(total, 2),
}