feat: P1批次2 报价/产品域(型材系列管理/副梃/成本口径毛利/报价分享)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# 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 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)
|
||||
|
||||
@@ -492,3 +492,12 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
|
||||
| 删除阳光房设计 | 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 拖线标注)。
|
||||
|
||||
### 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 公开页 |
|
||||
|
||||
@@ -3,25 +3,46 @@
|
||||
<el-tabs v-model="tab">
|
||||
<!-- ============ 产品价格本 ============ -->
|
||||
<el-tab-pane label="产品价格本" name="products">
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never" header="型材系列">
|
||||
<el-tree ref="catTreeRef" :data="catelogs" node-key="id"
|
||||
:props="{ label: 'name', children: 'children' }" default-expand-all
|
||||
highlight-current @node-click="onCatClick" />
|
||||
<div class="cat-ops">
|
||||
<el-input v-model="newCat" size="small" placeholder="新系列名" style="width:100px" />
|
||||
<el-button size="small" type="primary" plain @click="addCat(0)">加系列</el-button>
|
||||
<el-button v-if="currentCat" size="small" plain @click="addChild">加子类</el-button>
|
||||
</div>
|
||||
<div class="cat-ops" v-if="currentCat">
|
||||
<el-button size="small" text type="primary" @click="renameCat">改名</el-button>
|
||||
<el-button size="small" text type="danger" @click="removeCat">删除</el-button>
|
||||
<span class="tip">已选:{{ currentCat.name }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<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-input v-model="keyword" placeholder="名称/规格" clearable style="width:180px" @keyup.enter="load" />
|
||||
<el-select v-model="material" placeholder="全部类型" clearable style="width:120px" @change="load">
|
||||
<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-button type="primary" @click="load">查询</el-button>
|
||||
<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="140" />
|
||||
<el-table-column label="类型" width="80">
|
||||
<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="120" />
|
||||
<el-table-column prop="unit" label="单位" width="70" />
|
||||
<el-table-column prop="price" label="售价" width="90" />
|
||||
<el-table-column prop="cost" label="成本" width="90" />
|
||||
<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>
|
||||
@@ -31,6 +52,36 @@
|
||||
</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>
|
||||
|
||||
<!-- ============ 价格脚本 ============ -->
|
||||
@@ -107,12 +158,55 @@ const loading = ref(false)
|
||||
const editVisible = ref(false)
|
||||
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() {
|
||||
loading.value = true
|
||||
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}`)
|
||||
rows.value = d.data
|
||||
total.value = d.total
|
||||
@@ -120,7 +214,7 @@ async function load() {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -188,7 +282,7 @@ async function test(row: any) {
|
||||
ElMessage.success(`试算结果 ¥${d.result}`)
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadScripts() })
|
||||
onMounted(() => { load(); loadScripts(); loadCats() })
|
||||
</script>
|
||||
|
||||
<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; }
|
||||
.var-tag { font-family: Consolas, monospace; }
|
||||
.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>
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="glass_area" label="玻璃㎡" width="90" />
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="(['info', 'primary', 'success', 'danger'] as const)[row.status]">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""业务扩展路由:产品价格本、报价单、售后、量尺、阳光房订单过滤。"""
|
||||
"""业务扩展路由:产品价格本、产品分类(型材系列)、报价单、售后、量尺、阳光房订单过滤。"""
|
||||
import datetime
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import func, or_, select
|
||||
from huamenchuang.core.database import SessionLocal
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
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
|
||||
|
||||
router = APIRouter(tags=["业务扩展"])
|
||||
@@ -18,21 +18,77 @@ router = APIRouter(tags=["业务扩展"])
|
||||
|
||||
class ProductBody(BaseModel):
|
||||
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 = ""
|
||||
unit: str = "米"
|
||||
price: float = 0
|
||||
cost: float = 0
|
||||
note: str = ""
|
||||
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")
|
||||
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]
|
||||
if material:
|
||||
conds.append(Product.material == material)
|
||||
if catelog_id:
|
||||
conds.append(Product.catelog_id == catelog_id)
|
||||
if kw := keyword.strip():
|
||||
like = f"%{kw}%"
|
||||
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([{
|
||||
"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,
|
||||
"note": p.note,
|
||||
"catelog_id": p.catelog_id, "note": p.note,
|
||||
} 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:
|
||||
"""报价输出:附成本口径(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 {
|
||||
"id": q.id, "quote_no": q.quote_no, "customer_id": q.customer_id,
|
||||
"customer_name": q.customer_name, "contract_id": q.contract_id,
|
||||
"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),
|
||||
"cost_total": cost_total,
|
||||
"profit": round(float(q.total) - cost_total, 2),
|
||||
"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")
|
||||
async def create_quotation(body: QuotationBody, user: CurrentUser, db: DbSession):
|
||||
items = await _apply_cost(db, user.company_id, body.items)
|
||||
q = Quotation(
|
||||
company_id=user.company_id, user_id=user.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"},
|
||||
)
|
||||
db.add(q)
|
||||
@@ -163,7 +245,8 @@ async def update_quotation(qid: int, body: QuotationBody, user: CurrentUser, db:
|
||||
return fail("报价单不存在")
|
||||
for k, v in body.model_dump().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:
|
||||
setattr(q, k, v)
|
||||
return ok(data=_quote_out(q), message="更新成功")
|
||||
|
||||
Reference in New Issue
Block a user