feat: P1批次2 报价/产品域(型材系列管理/副梃/成本口径毛利/报价分享)

This commit is contained in:
HHY
2026-09-25 06:24:06 -07:00
parent 76fd0ced14
commit d05a91b7bc
5 changed files with 234 additions and 39 deletions
+2
View File
@@ -1,5 +1,7 @@
# CHANGELOG # CHANGELOG
- [2026-09-25 21:00] FEAT: v2.30 P1 批次2 报价/产品域 3 项。①型材系列管理: /api/product-catelogs 树 CRUD(系列下有产品禁删)+产品挂系列(catelog_id)+按系列筛选, 产品页左树右表布局+编辑表单系列选择+补回丢失的产品编辑对话框; ②副梃计价收尾: 产品类别加 subbar(按尺寸/米计价, 随逐格编辑 assigns.subBar 算料); ③成本报价: 报价保存/更新按产品名匹配价格本成本写行 _cost(quote_by_cost 语义)+响应加 cost_total/profit, 报价页加成本/毛利列+分享链接(quote_link 走 shares type=quote); 实测: 成本匹配 6×38=228 毛利 84; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/api/business.py, frontend/src/views/ProductsView.vue, frontend/src/views/QuotationsView.vue, doc/API.md)
- [2026-09-25 20:10] FEAT: v2.29 P1 批次1 设计域 5 项。①AI 推荐窗型: api/recommend.py 规则引擎(洞口宽高比/玻璃面积比/开扇数/门类/纱扇打分)+需求标签规则(通风/采光/隔音/安全/景观/通行)+可解释理由 TOP5, 样板库页"AI 推荐"Tab; ②通用分享体系: ShareItem 模型+POST/GET /api/shares(render/combine/quote/sunny 四类)+公开页 /share/s/:token(大图/报价明细/组合只读画布), 组合页/AI 炫图/报价单三处分享按钮; ③阳光房设计: SunroomDesign 模型+CRUD+SunroomCanvas 参数化绘图(flat 平顶/gable 人字顶 正视+俯视+尺寸标注)+AI 效果图(scene=sunroom-*, 提示词含 bays/footprint)+设计分享; ④量尺拍照标注: 照片上传+Canvas 拖拽画尺寸线(颜色/标签/撤销)+合成图存洞口行 photo; ⑤扇/玻璃逐格指定: SashAssign(decs 同型自研扩展)+画布"扇/玻璃"对话框(五类开启类型/内外开/铰链侧/副梃/玻璃配置)+2D 开向三角渲染+takeoff 计指定扇框料五金副梃(subbar 25 元/米); vue-tsc 0 错+构建通过, 推荐/阳光房/分享 API 实测通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/shares.py, src/huamenchuang/api/recommend.py, src/huamenchuang/api/sunroom.py, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/services/quote_service.py, frontend/src/views/ShareView.vue, frontend/src/views/SunnyHouseView.vue, frontend/src/views/TemplatesView.vue, frontend/src/views/MeasureView.vue, frontend/src/views/CombineView.vue, frontend/src/views/AIRenderView.vue, frontend/src/views/QuotationsView.vue, frontend/src/components/SunroomCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/router/index.ts, doc/API.md) - [2026-09-25 20:10] FEAT: v2.29 P1 批次1 设计域 5 项。①AI 推荐窗型: api/recommend.py 规则引擎(洞口宽高比/玻璃面积比/开扇数/门类/纱扇打分)+需求标签规则(通风/采光/隔音/安全/景观/通行)+可解释理由 TOP5, 样板库页"AI 推荐"Tab; ②通用分享体系: ShareItem 模型+POST/GET /api/shares(render/combine/quote/sunny 四类)+公开页 /share/s/:token(大图/报价明细/组合只读画布), 组合页/AI 炫图/报价单三处分享按钮; ③阳光房设计: SunroomDesign 模型+CRUD+SunroomCanvas 参数化绘图(flat 平顶/gable 人字顶 正视+俯视+尺寸标注)+AI 效果图(scene=sunroom-*, 提示词含 bays/footprint)+设计分享; ④量尺拍照标注: 照片上传+Canvas 拖拽画尺寸线(颜色/标签/撤销)+合成图存洞口行 photo; ⑤扇/玻璃逐格指定: SashAssign(decs 同型自研扩展)+画布"扇/玻璃"对话框(五类开启类型/内外开/铰链侧/副梃/玻璃配置)+2D 开向三角渲染+takeoff 计指定扇框料五金副梃(subbar 25 元/米); vue-tsc 0 错+构建通过, 推荐/阳光房/分享 API 实测通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/shares.py, src/huamenchuang/api/recommend.py, src/huamenchuang/api/sunroom.py, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/services/quote_service.py, frontend/src/views/ShareView.vue, frontend/src/views/SunnyHouseView.vue, frontend/src/views/TemplatesView.vue, frontend/src/views/MeasureView.vue, frontend/src/views/CombineView.vue, frontend/src/views/AIRenderView.vue, frontend/src/views/QuotationsView.vue, frontend/src/components/SunroomCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/router/index.ts, doc/API.md)
- [2026-09-25 18:30] FEAT: v2.28 AI 炫图接入 gpt-image-2.5 云端生图+提示词工程。①services/ai_prompt.py 结构化提示词构建器: 从 drawing_data 提取窗型特征(组合单元/宽高mm/格数/扇类型平开推拉内开内倒对开门/开向/纱扇/圆弧/格条)→专业英文提示词(主体先行/材质风格映射/摄影光照/负面约束"保持分格勿增删扇"/质量词)+按宽高比自动选尺寸(1024/1536x1024/1024x1536); ②api/ai_render.py 代理(Key 仅后端 .env HMC_AI_API_KEY, 已 gitignore): POST /ai-render/prompt 提示词预览 + /ai-render/generate(quick→generations, photo→edits 参考图合成, httpx 180s 超时, 错误分级); ③config .env 锚定项目根(修复 cwd 相对路径失效); ④前端 AIRenderView 双模式主按钮接云端(photo 模式参考图压缩≤1536px JPEG), 结果大图+提示词折叠面板+下载, 未配 Key 降级本地 4 方案; ⑤实测: quick 生图 28s 返回三格平开窗产品级渲染(把手/铰链/投影正确), photo 合成 55s 返回窗型融入照片; 端到端 puppeteer 验证通过(shots/verify-aicloud.png); vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/api/ai_render.py, src/huamenchuang/api/router.py, src/huamenchuang/core/config.py, frontend/src/views/AIRenderView.vue, frontend/src/api/index.ts, frontend/scripts/verify-aicloud.mjs, doc/API.md) - [2026-09-25 18:30] FEAT: v2.28 AI 炫图接入 gpt-image-2.5 云端生图+提示词工程。①services/ai_prompt.py 结构化提示词构建器: 从 drawing_data 提取窗型特征(组合单元/宽高mm/格数/扇类型平开推拉内开内倒对开门/开向/纱扇/圆弧/格条)→专业英文提示词(主体先行/材质风格映射/摄影光照/负面约束"保持分格勿增删扇"/质量词)+按宽高比自动选尺寸(1024/1536x1024/1024x1536); ②api/ai_render.py 代理(Key 仅后端 .env HMC_AI_API_KEY, 已 gitignore): POST /ai-render/prompt 提示词预览 + /ai-render/generate(quick→generations, photo→edits 参考图合成, httpx 180s 超时, 错误分级); ③config .env 锚定项目根(修复 cwd 相对路径失效); ④前端 AIRenderView 双模式主按钮接云端(photo 模式参考图压缩≤1536px JPEG), 结果大图+提示词折叠面板+下载, 未配 Key 降级本地 4 方案; ⑤实测: quick 生图 28s 返回三格平开窗产品级渲染(把手/铰链/投影正确), photo 合成 55s 返回窗型融入照片; 端到端 puppeteer 验证通过(shots/verify-aicloud.png); vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/api/ai_render.py, src/huamenchuang/api/router.py, src/huamenchuang/core/config.py, frontend/src/views/AIRenderView.vue, frontend/src/api/index.ts, frontend/scripts/verify-aicloud.mjs, doc/API.md)
+9
View File
@@ -492,3 +492,12 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
| 删除阳光房设计 | DELETE | `/api/sunroom/designs/{id}` | 路径 | | | 删除阳光房设计 | DELETE | `/api/sunroom/designs/{id}` | 路径 | |
**逐格扇/玻璃指定**(自研扩展 `drawing_data.sm[i].assigns[] = [{ri, sash:{kind: fixed/casement/awning/sliding/kfc, openToward, openDirection}, subBar, glass}]`):画布选格 → "扇/玻璃"对话框指定开启类型/方向/铰链侧/副梃/玻璃配置;2D 画开向三角与副梃线;算料计入指定扇框料+五金(`sash_count` 含 assigns)与副梃长度(`subbar` 默认 25 元/米,价格本可覆盖)。量尺拍照标注:洞口行 `photo` 字段存标注合成图(前端 Canvas 拖线标注)。 **逐格扇/玻璃指定**(自研扩展 `drawing_data.sm[i].assigns[] = [{ri, sash:{kind: fixed/casement/awning/sliding/kfc, openToward, openDirection}, subBar, glass}]`):画布选格 → "扇/玻璃"对话框指定开启类型/方向/铰链侧/副梃/玻璃配置;2D 画开向三角与副梃线;算料计入指定扇框料+五金(`sash_count` 含 assigns)与副梃长度(`subbar` 默认 25 元/米,价格本可覆盖)。量尺拍照标注:洞口行 `photo` 字段存标注合成图(前端 Canvas 拖线标注)。
### 12.25 P1 报价/产品域(v2.30)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 系列树 | GET | `/api/product-catelogs` | — | 型材系列树(对齐 product-catelog),产品价格本页左侧管理 |
| 新增/改名/删系列 | 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 公开页 |
+127 -32
View File
@@ -3,34 +3,85 @@
<el-tabs v-model="tab"> <el-tabs v-model="tab">
<!-- ============ 产品价格本 ============ --> <!-- ============ 产品价格本 ============ -->
<el-tab-pane label="产品价格本" name="products"> <el-tab-pane label="产品价格本" name="products">
<div class="toolbar"> <el-row :gutter="14">
<el-input v-model="keyword" placeholder="名称/规格" clearable style="width:200px" @keyup.enter="load" /> <el-col :span="6">
<el-select v-model="material" placeholder="全部类型" clearable style="width:130px" @change="load"> <el-card shadow="never" header="型材系列">
<el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" /> <el-tree ref="catTreeRef" :data="catelogs" node-key="id"
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" /> :props="{ label: 'name', children: 'children' }" default-expand-all
</el-select> highlight-current @node-click="onCatClick" />
<el-button type="primary" @click="load">查询</el-button> <div class="cat-ops">
<div style="flex:1" /> <el-input v-model="newCat" size="small" placeholder="新系列名" style="width:100px" />
<el-button type="primary" plain @click="openEdit()">新增产品</el-button> <el-button size="small" type="primary" plain @click="addCat(0)">加系列</el-button>
</div> <el-button v-if="currentCat" size="small" plain @click="addChild">加子类</el-button>
<el-table :data="rows" v-loading="loading" stripe> </div>
<el-table-column prop="name" label="名称" min-width="140" /> <div class="cat-ops" v-if="currentCat">
<el-table-column label="类型" width="80"> <el-button size="small" text type="primary" @click="renameCat">改名</el-button>
<template #default="{ row }">{{ label(row.material) }}</template> <el-button size="small" text type="danger" @click="removeCat">删除</el-button>
</el-table-column> <span class="tip">已选:{{ currentCat.name }}</span>
<el-table-column prop="spec" label="规格" width="120" /> </div>
<el-table-column prop="unit" label="单位" width="70" /> </el-card>
<el-table-column prop="price" label="售价" width="90" /> </el-col>
<el-table-column prop="cost" label="成本" width="90" /> <el-col :span="18">
<el-table-column label="操作" width="140" fixed="right"> <div class="toolbar">
<template #default="{ row }"> <el-input v-model="keyword" placeholder="名称/规格" clearable style="width:180px" @keyup.enter="load" />
<el-button size="small" @click="openEdit(row)">编辑</el-button> <el-select v-model="material" placeholder="全部类型" clearable style="width:120px" @change="load">
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button> <el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" />
</template> <el-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
</el-table-column> <el-option value="subbar" label="副梃" />
</el-table> </el-select>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next" <el-button type="primary" @click="load">查询</el-button>
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" /> <el-button v-if="currentCat" text @click="currentCat = null; 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="name" label="名称" min-width="130" />
<el-table-column label="类型" width="75">
<template #default="{ row }">{{ label(row.material) }}</template>
</el-table-column>
<el-table-column prop="spec" label="规格" width="110" />
<el-table-column prop="unit" label="单位" width="65" />
<el-table-column prop="price" label="售价" width="85" />
<el-table-column prop="cost" label="成本" width="85" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(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-col>
</el-row>
<!-- 产品编辑 -->
<el-dialog v-model="editVisible" :title="form.id ? '编辑产品' : '新增产品'" width="480px" append-to-body>
<el-form :model="form" label-width="70px">
<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-option value="subbar" label="副梃" />
</el-select>
</el-form-item>
<el-form-item label="系列">
<el-tree-select v-model="form.catelog_id" :data="catelogs" node-key="id"
:props="{ label: 'name', children: 'children' }" check-strictly clearable
style="width:100%" placeholder="所属系列(可选)" />
</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.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.cost" :min="0" :precision="2" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.note" type="textarea" /></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-tab-pane> </el-tab-pane>
<!-- ============ 价格脚本 ============ --> <!-- ============ 价格脚本 ============ -->
@@ -107,12 +158,55 @@ const loading = ref(false)
const editVisible = ref(false) const editVisible = ref(false)
const form = reactive<any>({ id: 0, name: '', material: 'profile', spec: '', unit: '米', price: 0, cost: 0, note: '' }) const form = reactive<any>({ id: 0, name: '', material: 'profile', spec: '', unit: '米', price: 0, cost: 0, note: '' })
const label = (m: string) => ({ profile: '型材', glass: '玻璃', hardware: '五金', addon: '配件' } as Record<string, string>)[m] ?? m const label = (m: string) => ({ profile: '型材', glass: '玻璃', hardware: '五金', addon: '配件', subbar: '副梃' } as Record<string, string>)[m] ?? m
// ---- 型材系列(product-catelog) ----
const catelogs = ref<any[]>([])
const currentCat = ref<any>(null)
const newCat = ref('')
const catTreeRef = ref()
async function loadCats() {
catelogs.value = (await api<any[]>('get', '/api/product-catelogs')) ?? []
}
function onCatClick(node: any) {
currentCat.value = node
load()
}
async function addCat(fid: number) {
if (!newCat.value.trim()) return ElMessage.warning('请填写系列名')
await api('post', '/api/product-catelogs', { name: newCat.value.trim(), fid })
newCat.value = ''
loadCats()
}
async function addChild() {
if (!newCat.value.trim()) return ElMessage.warning('请填写子类名')
await addCat(currentCat.value.id)
}
async function renameCat() {
const name = await ElMessageBox.prompt('修改系列名称', '改名', { inputValue: currentCat.value.name })
await api('put', `/api/product-catelogs/${currentCat.value.id}`, { name: name.value, fid: currentCat.value.fid })
ElMessage.success('已改名')
loadCats()
}
async function removeCat() {
await ElMessageBox.confirm(`确定删除系列「${currentCat.value.name}」?`, '提示', { type: 'warning' })
try {
await api('delete', `/api/product-catelogs/${currentCat.value.id}`)
currentCat.value = null
loadCats(); load()
} catch { /* 拦截器已提示(系列下有产品) */ }
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const qs = `page=${page.value}&page_size=${page_size}&keyword=${keyword.value}${material.value ? `&material=${material.value}` : ''}` const qs = `page=${page.value}&page_size=${page_size}&keyword=${keyword.value}${material.value ? `&material=${material.value}` : ''}${currentCat.value ? `&catelog_id=${currentCat.value.id}` : ''}`
const d = await api<any>('get', `/api/products?${qs}`) const d = await api<any>('get', `/api/products?${qs}`)
rows.value = d.data rows.value = d.data
total.value = d.total total.value = d.total
@@ -120,7 +214,7 @@ async function load() {
} }
function openEdit(row?: any) { function openEdit(row?: any) {
Object.assign(form, row ?? { id: 0, name: '', material: 'profile', spec: '', unit: '米', price: 0, cost: 0, note: '' }) Object.assign(form, row ?? { id: 0, name: '', material: 'profile', spec: '', unit: '米', price: 0, cost: 0, note: '', catelog_id: currentCat.value?.id ?? null })
editVisible.value = true editVisible.value = true
} }
@@ -188,7 +282,7 @@ async function test(row: any) {
ElMessage.success(`试算结果 ¥${d.result}`) ElMessage.success(`试算结果 ¥${d.result}`)
} }
onMounted(() => { load(); loadScripts() }) onMounted(() => { load(); loadScripts(); loadCats() })
</script> </script>
<style scoped> <style scoped>
@@ -196,4 +290,5 @@ onMounted(() => { load(); loadScripts() })
.tip { font-size: 12px; color: #909399; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } .tip { font-size: 12px; color: #909399; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.var-tag { font-family: Consolas, monospace; } .var-tag { font-family: Consolas, monospace; }
.formula-help { font-size: 12px; color: #909399; line-height: 1.5; margin-top: 4px; } .formula-help { font-size: 12px; color: #909399; line-height: 1.5; margin-top: 4px; }
.cat-ops { display: flex; gap: 6px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
</style> </style>
+6
View File
@@ -14,6 +14,12 @@
</el-table-column> </el-table-column>
<el-table-column prop="glass_area" label="玻璃㎡" width="90" /> <el-table-column prop="glass_area" label="玻璃㎡" width="90" />
<el-table-column prop="total" label="总价" width="110" /> <el-table-column prop="total" label="总价" width="110" />
<el-table-column label="成本/毛利" width="150">
<template #default="{ row }">
<span class="cost">¥{{ row.cost_total ?? 0 }}</span>
<span class="profit"> / ¥{{ row.profit ?? row.total }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90"> <el-table-column label="状态" width="90">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small" :type="(['info', 'primary', 'success', 'danger'] as const)[row.status]"> <el-tag size="small" :type="(['info', 'primary', 'success', 'danger'] as const)[row.status]">
+90 -7
View File
@@ -1,4 +1,4 @@
"""业务扩展路由:产品价格本、报价单、售后、量尺、阳光房订单过滤。""" """业务扩展路由:产品价格本、产品分类(型材系列)、报价单、售后、量尺、阳光房订单过滤。"""
import datetime import datetime
from fastapi import APIRouter from fastapi import APIRouter
@@ -8,7 +8,7 @@ from sqlalchemy import func, or_, select
from huamenchuang.core.database import SessionLocal from huamenchuang.core.database import SessionLocal
from huamenchuang.core.deps import CurrentUser, DbSession from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.business import AfterSale, MeasureOrder, Product, Quotation from huamenchuang.models.business import AfterSale, MeasureOrder, Product, ProductCatelog, Quotation
from huamenchuang.models.order import Order from huamenchuang.models.order import Order
router = APIRouter(tags=["业务扩展"]) router = APIRouter(tags=["业务扩展"])
@@ -18,21 +18,77 @@ router = APIRouter(tags=["业务扩展"])
class ProductBody(BaseModel): class ProductBody(BaseModel):
name: str = Field(min_length=1, max_length=128) name: str = Field(min_length=1, max_length=128)
material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon)$") material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon|subbar)$")
spec: str = "" spec: str = ""
unit: str = "米" unit: str = "米"
price: float = 0 price: float = 0
cost: float = 0 cost: float = 0
note: str = "" note: str = ""
enabled: int = 1 enabled: int = 1
catelog_id: int | None = None
@router.get("/product-catelogs")
async def list_catelogs(user: CurrentUser, db: DbSession):
"""产品分类树(型材系列管理,对齐目标站 product-catelog/script 系列)。"""
rows = list((await db.execute(select(ProductCatelog).where(
ProductCatelog.company_id == user.company_id).order_by(ProductCatelog.id))).scalars())
nodes = [{"id": r.id, "name": r.name, "fid": r.fid} for r in rows]
# 平铺 → 树
by_fid: dict[int, list] = {}
for n in nodes:
by_fid.setdefault(n["fid"], []).append({**n, "children": []})
def build(fid: int):
for n in by_fid.get(fid, []):
n["children"] = build(n["id"])
return by_fid.get(fid, [])
return ok(data=build(0))
class CatelogBody(BaseModel):
name: str = Field(min_length=1, max_length=64)
fid: int = 0
@router.post("/product-catelogs")
async def create_catelog(body: CatelogBody, user: CurrentUser, db: DbSession):
c = ProductCatelog(company_id=user.company_id, name=body.name, fid=body.fid)
db.add(c)
await db.flush()
return ok(data={"id": c.id, "name": c.name}, message="系列已创建")
@router.put("/product-catelogs/{cid}")
async def update_catelog(cid: int, body: CatelogBody, user: CurrentUser, db: DbSession):
c = await db.get(ProductCatelog, cid)
if c is None or c.company_id != user.company_id:
return fail("系列不存在")
c.name = body.name
c.fid = body.fid
return ok(message="系列已更新")
@router.delete("/product-catelogs/{cid}")
async def delete_catelog(cid: int, user: CurrentUser, db: DbSession):
c = await db.get(ProductCatelog, cid)
if c is None or c.company_id != user.company_id:
return fail("系列不存在")
used = (await db.execute(select(func.count()).select_from(Product).where(
Product.catelog_id == cid))).scalar_one()
if used:
return fail(f"该系列下还有 {used} 个产品,请先移出")
await db.delete(c)
return ok(message="系列已删除")
@router.get("/products") @router.get("/products")
async def list_products(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15, async def list_products(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
keyword: str = "", material: str | None = None): keyword: str = "", material: str | None = None, catelog_id: int | None = None):
conds = [Product.company_id == user.company_id] conds = [Product.company_id == user.company_id]
if material: if material:
conds.append(Product.material == material) conds.append(Product.material == material)
if catelog_id:
conds.append(Product.catelog_id == catelog_id)
if kw := keyword.strip(): if kw := keyword.strip():
like = f"%{kw}%" like = f"%{kw}%"
conds.append(or_(Product.name.like(like), Product.spec.like(like))) conds.append(or_(Product.name.like(like), Product.spec.like(like)))
@@ -44,7 +100,7 @@ async def list_products(user: CurrentUser, db: DbSession, page: int = 1, page_si
return ok(data=paginated([{ return ok(data=paginated([{
"id": p.id, "name": p.name, "material": p.material, "spec": p.spec, "unit": p.unit, "id": p.id, "name": p.name, "material": p.material, "spec": p.spec, "unit": p.unit,
"price": float(p.price), "cost": float(p.cost), "enabled": p.enabled, "price": float(p.price), "cost": float(p.cost), "enabled": p.enabled,
"note": p.note, "catelog_id": p.catelog_id, "note": p.note,
} for p in items], page, page_size, total)) } for p in items], page, page_size, total))
@@ -105,21 +161,47 @@ async def _next_quote_no(db, company_id: int) -> str:
def _quote_out(q: Quotation) -> dict: def _quote_out(q: Quotation) -> dict:
"""报价输出:附成本口径(quote_by_cost 语义)——items 内 _cost 逐行汇总。"""
import json as _json
try:
items = _json.loads(q.items or "[]")
except Exception:
items = []
cost_total = round(sum(float(i.get("_cost") or 0) for i in items), 2)
return { return {
"id": q.id, "quote_no": q.quote_no, "customer_id": q.customer_id, "id": q.id, "quote_no": q.quote_no, "customer_id": q.customer_id,
"customer_name": q.customer_name, "contract_id": q.contract_id, "customer_name": q.customer_name, "contract_id": q.contract_id,
"template_id": q.template_id, "glass_area": float(q.glass_area), "template_id": q.template_id, "glass_area": float(q.glass_area),
"width_mm": q.width_mm, "height_mm": q.height_mm, "total": float(q.total), "width_mm": q.width_mm, "height_mm": q.height_mm, "total": float(q.total),
"cost_total": cost_total,
"profit": round(float(q.total) - cost_total, 2),
"status": q.status, "note": q.note, "created_at": str(q.created_at), "status": q.status, "note": q.note, "created_at": str(q.created_at),
} }
async def _apply_cost(db, company_id: int, items: list[dict]) -> list[dict]:
"""成本口径:按产品名匹配价格本成本,写入每行 _cost(quote_by_cost 语义)。"""
names = [str(i.get("name") or "").strip() for i in items]
names = [n for n in names if n]
cost_map: dict[str, float] = {}
if names:
rows = (await db.execute(select(Product.name, Product.cost).where(
Product.company_id == company_id, Product.name.in_(names)))).all()
cost_map = {n: float(c or 0) for n, c in rows}
for i in items:
name = str(i.get("name") or "").strip()
qty = float(i.get("qty") or 0)
i["_cost"] = round(cost_map.get(name, float(i.get("cost") or 0)) * qty, 2)
return items
@router.post("/quotations") @router.post("/quotations")
async def create_quotation(body: QuotationBody, user: CurrentUser, db: DbSession): async def create_quotation(body: QuotationBody, user: CurrentUser, db: DbSession):
items = await _apply_cost(db, user.company_id, body.items)
q = Quotation( q = Quotation(
company_id=user.company_id, user_id=user.id, company_id=user.company_id, user_id=user.id,
quote_no=await _next_quote_no(db, user.company_id), quote_no=await _next_quote_no(db, user.company_id),
items=__import__("json").dumps(body.items, ensure_ascii=False), items=__import__("json").dumps(items, ensure_ascii=False),
**{k: v for k, v in body.model_dump().items() if k != "items"}, **{k: v for k, v in body.model_dump().items() if k != "items"},
) )
db.add(q) db.add(q)
@@ -163,7 +245,8 @@ async def update_quotation(qid: int, body: QuotationBody, user: CurrentUser, db:
return fail("报价单不存在") return fail("报价单不存在")
for k, v in body.model_dump().items(): for k, v in body.model_dump().items():
if k == "items": if k == "items":
q.items = __import__("json").dumps(v, ensure_ascii=False) q.items = __import__("json").dumps(
await _apply_cost(db, user.company_id, v), ensure_ascii=False)
else: else:
setattr(q, k, v) setattr(q, k, v)
return ok(data=_quote_out(q), message="更新成功") return ok(data=_quote_out(q), message="更新成功")