feat: v0.3 报价算料引擎与仓库库存模块
后端: - 报价算料: 从drawing_data几何提取型材清单(外框/中梃/扇框投影长度) 与玻璃面积(鞋带公式), 套价格本计价, 支持样板/订单/自定义图纸三入口 - 仓库库存: 材料CRUD/出入库/流水/负库存拦截/预警线 - SQLite默认路径锚定项目根目录(修复cwd依赖) 前端: - 仓库库存页(筛选/出入库对话框/预警高亮) - 算料报价弹窗(样板库与订单详情入口, 支持打印) - 路由与导航更新(8页面) 回归: 9个API端点全部200, 报价/优化/SPA路由验证通过
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# 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 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)
|
||||
|
||||
+22
-4
@@ -258,21 +258,39 @@
|
||||
|---|---|---|---|---|
|
||||
| 一维优化 | 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 登录 |
|
||||
| `/dashboard` | 工作台 | 订单/意向单/客户/合同统计 + 快捷入口 |
|
||||
| `/orders` | 订单中心 | 搜索/新建(选合同+样板自动带图纸)/详情抽屉(**画布渲染 drawing_data**)+状态机操作+生产进度更新 |
|
||||
| `/orders` | 订单中心 | 搜索/新建(选合同+样板自动带图纸)/详情抽屉(**画布渲染 drawing_data**)+状态机操作+生产进度更新+算料报价+打印 |
|
||||
| `/contracts` | 合同管理 | 列表/新建(选客户带出信息)/意向单转正式/一键下订单 |
|
||||
| `/customers` | 客户管理 | CRUD + 搜索 |
|
||||
| `/templates` | 样板库 | 254 套样板网格(平面图) + 分类筛选 + 点击预览(**画布渲染**) + 用此样板下单 |
|
||||
| `/templates` | 样板库 | 254 套样板网格(平面图) + 分类筛选 + 画布预览 + 算料报价 + 用此样板下单 |
|
||||
| `/warehouse` | 仓库库存 | 材料 CRUD + 出入库(流水) + 库存预警高亮 |
|
||||
| `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) |
|
||||
|
||||
**画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 扇开向三角标记(od 角度) → 总宽高尺寸标注;支持滚轮缩放、拖拽平移、双击复位。
|
||||
**报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。
|
||||
|
||||
### 12.9 种子数据
|
||||
### 12.11 种子数据
|
||||
|
||||
```bash
|
||||
uv sync # 安装依赖
|
||||
|
||||
@@ -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>
|
||||
@@ -16,6 +16,7 @@ const router = createRouter({
|
||||
{ path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } },
|
||||
{ path: 'customers', component: () => import('../views/CustomersView.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: '优化下料' } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<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="/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>
|
||||
</el-aside>
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
<span>客户:{{ detail.customer_name || '-' }}</span>
|
||||
<span>金额:{{ detail.money }}</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 class="canvas-box">
|
||||
<WindoorCanvas :drawing-data="detail.drawing_data" />
|
||||
@@ -92,6 +94,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
<QuoteDialog ref="quoteRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -101,8 +104,12 @@ import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import WindoorCanvas from '../components/WindoorCanvas.vue'
|
||||
import QuoteDialog from '../components/QuoteDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const quoteRef = ref()
|
||||
|
||||
function printOrder() { window.print() }
|
||||
|
||||
const statusLabels: Record<number, string> = { 0: '意向单', 1: '正式单', 2: '生产中', 3: '已完工', 4: '已取消' }
|
||||
|
||||
|
||||
@@ -28,10 +28,12 @@
|
||||
<div class="meta">
|
||||
<span>尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm</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>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<QuoteDialog ref="quoteRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -40,8 +42,10 @@ import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../api'
|
||||
import WindoorCanvas from '../components/WindoorCanvas.vue'
|
||||
import QuoteDialog from '../components/QuoteDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const quoteRef = ref()
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
@@ -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>
|
||||
@@ -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="报价成功")
|
||||
@@ -1,7 +1,16 @@
|
||||
"""路由汇总。"""
|
||||
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.include_router(auth.router)
|
||||
@@ -10,3 +19,5 @@ api_router.include_router(customers.router)
|
||||
api_router.include_router(contracts.router)
|
||||
api_router.include_router(windoor.router)
|
||||
api_router.include_router(optimize.router)
|
||||
api_router.include_router(quote.router)
|
||||
api_router.include_router(warehouse.router)
|
||||
|
||||
@@ -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))
|
||||
@@ -1,11 +1,16 @@
|
||||
"""全局配置。开发默认 SQLite,生产可通过环境变量切 MySQL。"""
|
||||
"""全局配置。开发默认 SQLite(锚定项目根,避免 cwd 影响),生产可通过环境变量切 MySQL。"""
|
||||
from pathlib import Path
|
||||
|
||||
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):
|
||||
app_name: str = "huamenchuang"
|
||||
# 开发默认 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_algorithm: str = "HS256"
|
||||
jwt_expire_hours: int = 24
|
||||
|
||||
@@ -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]
|
||||
@@ -1,5 +1,11 @@
|
||||
"""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.user import User
|
||||
from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate
|
||||
@@ -11,7 +17,10 @@ __all__ = [
|
||||
"Order",
|
||||
"OrderLog",
|
||||
"OrderProcess",
|
||||
"ReceiptAccount",
|
||||
"StockMove",
|
||||
"User",
|
||||
"WarehouseItem",
|
||||
"WindoorCatelog",
|
||||
"WindoorTemplate",
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
"""合同/意向单:目标站契约 status="意向单" 起步,oids[] 记录关联订单。"""
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user