feat: P0-3 打印模板体系(区块化模板CRUD+打印中心重构)

This commit is contained in:
HHY
2026-09-25 03:13:38 -07:00
parent 3d0f43d567
commit 87f9869829
8 changed files with 360 additions and 28 deletions
+2
View File
@@ -1,5 +1,7 @@
# CHANGELOG
- [2026-09-25 12:20] FEAT: v2.22 P0-3 打印模板体系。①OrderFormat 模型(blocks 区块 JSON: title/info/image/table/sign)+种子 2 套默认模板; ②/api/order-formats CRUD(区块类型校验+is_default 唯一默认); ③打印中心重构为模板驱动: A4 预览按 blocks 渲染(BOM 块自动拉取 /orders/{id}/bom), 模板管理对话框(区块增删/上下移/title 文本/info 字段多选/table kind 选择); 修复 move(i) v-for 索引 string|number 类型; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/order_format.py, src/huamenchuang/api/router.py, src/huamenchuang/scripts/seed.py, frontend/src/views/PrinterView.vue, doc/API.md)
- [2026-09-25 11:40] FEAT: v2.21 P0-2 订单 BOM 计算单+Excel 导出。①quote_service.bom() 从 drawing_data 重放算料输出型材下料清单(calcbomOrder 语义); ②services/excel_service.py 通用 xlsx 工具(标题/带样式表格/列宽)+order_workbook 订单导出(信息+型材清单+玻璃五金汇总+签审行); ③GET /orders/{id}/bom + GET /orders/{id}/export(attachment 下载); ④前端订单详情抽屉加"BOM 单"弹窗(汇总+下料表+合计米数)+导出 Excel(apiDownload blob 带 token); openpyxl 入依赖; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/services/quote_service.py, src/huamenchuang/api/orders.py, frontend/src/api/index.ts, frontend/src/views/OrdersView.vue, doc/API.md)
- [2026-09-25 11:00] FEAT: v2.20 P0-1 价格脚本引擎。①services/formula.py 安全公式引擎(递归下降解析, 无 eval; 四则/括号/min/max/round/abs/ceil/floor/sqrt; ALLOWED_VARS 变量白名单+validate_formula 语法校验); ②PriceScript 模型(系列名/target/frame~total 六作用项/formula/enabled)+种子 5 条示例; ③/api/scripts CRUD+preview 试算(product-test 语义); ④offer-price 集成 use_scripts/scripts 参数, 行级 scripted 标记+script_total 直取; ⑤产品管理页拆双 Tab(价格本/脚本 CRUD+试算), 报价弹窗"脚本计价"标记; 前端 vue-tsc 0 错+构建通过 (Files: src/huamenchuang/services/formula.py, src/huamenchuang/models/business.py, src/huamenchuang/api/script.py, src/huamenchuang/api/quote.py, src/huamenchuang/services/quote_service.py, src/huamenchuang/scripts/seed.py, frontend/src/views/ProductsView.vue, frontend/src/components/QuoteDialog.vue, doc/API.md)
+15
View File
@@ -390,3 +390,18 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
|---|---|---|---|---|
| **订单 BOM 单** | GET | `/api/orders/{id}/bom` | 路径 | 对齐目标站 calcbomOrder;返回 `{bars:[{kind,note,length,qty}], glass_area_m2, sash_count, width_mm, height_mm}`(从 drawing_data 重放算料,不含价格) |
| **订单导出 Excel** | GET | `/api/orders/{id}/export` | 路径 | 对齐 orders/export;xlsx(订单信息+BOM 下料清单+玻璃/五金汇总),`Content-Disposition: attachment` |
### 12.17 打印模板 `/api/order-formats`(v2.20 新增,P0-打印模板体系)
对齐目标站 `order_format` 语义:模板=区块列表 `blocks[]`,前端按区块渲染 A4。
区块类型:`title`(text=标题文字)、`info`(fields=订单字段列表:order_no/customer_name/phone/address/count/area/money/created_at/note)、`image`(图纸缩略图)、`table`(kind=process 生产进度 / bom 下料清单)、`sign`(签字行)。
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 模板列表 | GET | `/api/order-formats` | — | 按 sort 排序 |
| 新增模板 | POST | `/api/order-formats` | `{name, order_type, blocks[], is_default?}` | 区块类型校验;is_default=1 时清除其他默认 |
| 编辑模板 | PUT | `/api/order-formats/{id}` | 同新增 | |
| 删除模板 | DELETE | `/api/order-formats/{id}` | 路径 | |
种子模板 2 套:标准生产订单(默认)、生产订单+BOM 下料单。
+191 -17
View File
@@ -8,23 +8,27 @@
<el-select v-model="orderId" filterable placeholder="选择订单" style="width:280px" @change="onPick">
<el-option v-for="o in orders" :key="o.id" :label="`${o.order_no} ${o.customer_name || ''}`" :value="o.id" />
</el-select>
<el-select v-model="formatId" style="width:200px" placeholder="打印模板" @change="onFormatChange">
<el-option v-for="f in formats" :key="f.id" :label="`${f.name}${f.is_default ? ' (默认)' : ''}`" :value="f.id" />
</el-select>
<el-button :disabled="!current" @click="fmtVisible = true">模板管理</el-button>
<el-button type="primary" :disabled="!current" @click="print">打印</el-button>
</div>
<!-- A4 预览:按模板区块渲染 -->
<div v-if="current" class="a4" id="print-area">
<h2 class="doc-title">生产订单</h2>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="订单号">{{ current.order_no }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ current.customer_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ current.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ current.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ current.count }}</el-descriptions-item>
<el-descriptions-item label="金额">¥{{ current.money }}</el-descriptions-item>
<template v-for="(b, i) in activeBlocks" :key="i">
<h2 v-if="b.type === 'title'" class="doc-title">{{ b.text }}</h2>
<el-descriptions v-else-if="b.type === 'info'" :column="3" border size="small">
<el-descriptions-item v-for="f in b.fields" :key="f" :label="FIELD_DEFS[String(f)]?.label ?? f">
{{ renderField(String(f)) }}
</el-descriptions-item>
</el-descriptions>
<div class="pic-box">
<div v-else-if="b.type === 'image'" class="pic-box">
<img v-if="current.pic_url" :src="current.pic_url" alt="图纸" />
<div v-else class="no-pic">暂无图纸缩略图</div>
</div>
<template v-else-if="b.type === 'table' && b.kind === 'process'">
<el-divider>生产进度</el-divider>
<table class="simple">
<tr v-for="p in current.processes" :key="p.name">
@@ -32,41 +36,206 @@
<td>{{ p.status === 2 ? `已完成 ${p.finish_time || ''}` : p.status === 1 ? '进行中' : '未开始' }}</td>
</tr>
</table>
<p class="sign">客户签字:____________  日期:____________</p>
</template>
<template v-else-if="b.type === 'table' && b.kind === 'bom'">
<el-divider>型材下料清单</el-divider>
<table class="simple">
<tr class="thead"><td>用途</td><td>单件长度(mm)</td><td>数量(件)</td><td>总长(m)</td></tr>
<tr v-for="(row, ri) in bom?.bars ?? []" :key="ri">
<td>{{ row.note || row.kind }}</td><td>{{ row.length }}</td><td>{{ row.qty }}</td>
<td>{{ (row.length * row.qty / 1000).toFixed(2) }}</td>
</tr>
<tr v-if="!bom?.bars?.length"><td colspan="4" class="no-pic">无下料数据</td></tr>
</table>
</template>
<p v-else-if="b.type === 'sign'" class="sign">客户签字:____________  日期:____________</p>
</template>
</div>
<el-empty v-else description="选择订单后预览打印" />
<!-- 模板管理 -->
<el-dialog v-model="fmtVisible" title="打印模板管理" width="680px">
<div class="fmt-list">
<el-table :data="formats" size="small" border>
<el-table-column prop="name" label="模板" min-width="150" />
<el-table-column label="默认" width="70">
<template #default="{ row }">
<el-tag v-if="row.is_default" size="small" type="success">默认</el-tag>
<el-button v-else size="small" text @click="setDefault(row)">设默认</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="110">
<template #default="{ row }">
<el-button size="small" @click="editFormat(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="removeFormat(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" type="primary" plain style="margin-top:8px" @click="newFormat">新增模板</el-button>
</div>
<el-divider content-position="left">{{ fmtForm.id ? `编辑:${fmtForm.name}` : '新增模板' }}</el-divider>
<el-form label-width="70px" size="small">
<el-form-item label="模板名"><el-input v-model="fmtForm.name" style="width:240px" /></el-form-item>
<el-form-item label="区块设计">
<div class="blocks-editor">
<div v-for="(b, i) in fmtForm.blocks" :key="i" class="block-row">
<el-tag size="small" effect="dark" class="block-tag">{{ BLOCK_LABEL[b.type] }}</el-tag>
<el-input v-if="b.type === 'title'" v-model="b.text" size="small" style="width:180px" placeholder="标题文字" />
<el-select v-if="b.type === 'info'" v-model="b.fields" multiple size="small" style="min-width:280px"
placeholder="选择展示字段">
<el-option v-for="(def, k) in FIELD_DEFS" :key="k" :label="def.label" :value="k" />
</el-select>
<el-select v-if="b.type === 'table'" v-model="b.kind" size="small" style="width:130px">
<el-option value="process" label="生产进度" /><el-option value="bom" label="BOM 下料清单" />
</el-select>
<span v-if="b.type === 'image'" class="block-note">订单图纸缩略图</span>
<span v-else-if="b.type === 'sign'" class="block-note">签字/日期行</span>
<div style="flex:1" />
<el-button size="small" text :disabled="i === 0" @click="move(i, -1)">↑</el-button>
<el-button size="small" text :disabled="i === fmtForm.blocks.length - 1" @click="move(i, 1)">↓</el-button>
<el-button size="small" text type="danger" @click="fmtForm.blocks.splice(i, 1)">删</el-button>
</div>
<el-dropdown @command="(t: string) => addBlock(t)">
<el-button size="small" type="primary" plain>+ 添加区块</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-for="(lbl, t) in BLOCK_LABEL" :key="t" :command="t">{{ lbl }}</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="fmtVisible = false">关闭</el-button>
<el-button type="primary" @click="saveFormat">保存模板</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const FIELD_DEFS: Record<string, { label: string; render: (o: any) => string }> = {
order_no: { label: '订单号', render: o => o.order_no },
customer_name: { label: '客户', render: o => o.customer_name || '-' },
phone: { label: '电话', render: o => o.phone || '-' },
address: { label: '地址', render: o => o.address || '-' },
count: { label: '数量', render: o => String(o.count ?? 0) },
area: { label: '面积', render: o => `${o.area ?? 0} ㎡` },
money: { label: '金额', render: o => `¥${o.money ?? 0}` },
created_at: { label: '下单时间', render: o => String(o.created_at ?? '').slice(0, 10) },
note: { label: '备注', render: o => o.note || '-' },
}
const BLOCK_LABEL: Record<string, string> = { title: '标题', info: '订单信息', image: '图纸', table: '表格', sign: '签字' }
const orders = ref<any[]>([])
const orderId = ref<number | undefined>()
const orderType = ref(0)
const current = ref<any>(null)
const bom = ref<any>(null)
const formats = ref<any[]>([])
const formatId = ref<number | undefined>()
const fmtVisible = ref(false)
const fmtForm = reactive<any>({ id: 0, name: '', blocks: [], is_default: 0 })
const activeBlocks = computed<any[]>(() => {
const f = formats.value.find(x => x.id === formatId.value)
return f?.blocks ?? []
})
function renderField(f: string) {
const def = FIELD_DEFS[f]
return def ? def.render(current.value) : ''
}
async function load() {
const d = orderType.value === 1
? await api<any>('get', `/api/sunny_house_orders?page=1&page_size=100`)
: await api<any>('post', '/api/orders/search', { page: 1, page_size: 100 })
orders.value = orderType.value === 1 ? d.data : d.data
const d = await api<any>('post', '/api/orders/search', { page: 1, page_size: 100, status: orderType.value })
orders.value = d.data
current.value = null
}
async function loadFormats(keep = false) {
formats.value = (await api<any[]>('get', '/api/order-formats')) ?? []
if (!keep) {
const def = formats.value.find(f => f.is_default) ?? formats.value[0]
formatId.value = def?.id
}
}
async function onPick(oid: number | undefined) {
if (!oid) { current.value = null; return }
current.value = await api<any>('get', `/api/orders/${oid}`)
await refreshBom()
}
async function refreshBom() {
bom.value = null
if (!current.value || !activeBlocks.value.some(b => b.type === 'table' && b.kind === 'bom')) return
try { bom.value = await api<any>('get', `/api/orders/${current.value.id}/bom`) } catch { /* 无图纸时静默 */ }
}
function onFormatChange() { refreshBom() }
function print() { window.print() }
onMounted(load)
// ---- 模板管理 ----
function newFormat() {
Object.assign(fmtForm, { id: 0, name: '', blocks: [{ type: 'title', text: '生产订单' }], is_default: 0 })
}
function editFormat(row: any) {
Object.assign(fmtForm, { id: row.id, name: row.name, blocks: JSON.parse(JSON.stringify(row.blocks)), is_default: row.is_default })
}
function addBlock(t: string) {
const seed: Record<string, any> = {
title: { type: 'title', text: '标题' },
info: { type: 'info', fields: ['order_no', 'customer_name', 'count', 'money'] },
image: { type: 'image' },
table: { type: 'table', kind: 'process' },
sign: { type: 'sign' },
}
fmtForm.blocks.push(JSON.parse(JSON.stringify(seed[t])))
}
function move(idx: number | string, dir: number) {
const i = Number(idx)
const arr = fmtForm.blocks
const [x] = arr.splice(i, 1)
arr.splice(i + dir, 0, x)
}
async function saveFormat() {
if (!fmtForm.name.trim()) return ElMessage.warning('请填写模板名')
if (fmtForm.id) await api('put', `/api/order-formats/${fmtForm.id}`, fmtForm)
else await api('post', '/api/order-formats', fmtForm)
ElMessage.success('模板已保存')
await loadFormats(true)
editFormat(formats.value.find(f => f.id === fmtForm.id)!)
}
async function setDefault(row: any) {
await api('put', `/api/order-formats/${row.id}`, { ...row, is_default: 1 })
ElMessage.success('已设为默认')
loadFormats(true)
}
async function removeFormat(row: any) {
await ElMessageBox.confirm(`确定删除模板「${row.name}」?`, '提示', { type: 'warning' })
await api('delete', `/api/order-formats/${row.id}`)
ElMessage.success('已删除')
await loadFormats(true)
if (formatId.value === row.id) formatId.value = formats.value[0]?.id
}
onMounted(() => { load(); loadFormats() })
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.a4 { width: 210mm; min-height: 240mm; margin: 0 auto; background: #fff; padding: 14mm; box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.doc-title { text-align: center; letter-spacing: 8px; }
.pic-box { height: 80mm; display: flex; align-items: center; justify-content: center; background: #f7f8f9; margin-top: 12px; }
@@ -74,7 +243,12 @@ onMounted(load)
.no-pic { color: #909399; }
.simple { width: 100%; border-collapse: collapse; }
.simple td { border: 1px solid #dcdfe6; padding: 5px 8px; font-size: 13px; }
.simple .thead td { background: #f5f7fa; font-weight: 600; }
.sign { margin-top: 30px; text-align: right; color: #606266; }
.blocks-editor { width: 100%; }
.block-row { display: flex; gap: 8px; align-items: center; padding: 4px 0; }
.block-tag { width: 64px; justify-content: center; }
.block-note { color: #909399; font-size: 12px; }
@media print {
.no-print { display: none !important; }
.a4 { box-shadow: none; width: 100%; }
+92
View File
@@ -0,0 +1,92 @@
"""打印模板路由:订单打印模板 CRUD(对齐目标站 order_format 语义)。"""
import json
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.extend import OrderFormat
router = APIRouter(prefix="/order-formats", tags=["打印模板"])
BLOCK_TYPES = ("title", "info", "image", "table", "sign")
TABLE_KINDS = ("process", "bom")
class FormatBody(BaseModel):
name: str = Field(min_length=1, max_length=64)
order_type: int = Field(default=0, ge=0, le=1)
blocks: list[dict] = Field(default_factory=list, description="模板区块列表")
is_default: int = Field(default=0, ge=0, le=1)
def _validate_blocks(blocks: list[dict]) -> str | None:
for b in blocks:
if b.get("type") not in BLOCK_TYPES:
return f"不支持的区块类型: {b.get('type')}"
if b.get("type") == "table" and b.get("kind") not in TABLE_KINDS:
return "table 区块需要 kind: process/bom"
return None
def _to_out(f: OrderFormat) -> dict:
return {"id": f.id, "name": f.name, "order_type": f.order_type,
"blocks": json.loads(f.blocks or "[]"), "is_default": f.is_default, "sort": f.sort}
@router.get("")
async def list_formats(user: CurrentUser, db: DbSession):
stmt = (select(OrderFormat).where(OrderFormat.company_id == user.company_id)
.order_by(OrderFormat.sort, OrderFormat.id))
rows = list((await db.execute(stmt)).scalars())
return ok(data=[_to_out(r) for r in rows])
@router.post("")
async def create_format(body: FormatBody, user: CurrentUser, db: DbSession):
err = _validate_blocks(body.blocks)
if err:
return fail(err)
fmt = OrderFormat(company_id=user.company_id,
name=body.name, order_type=body.order_type,
blocks=json.dumps(body.blocks, ensure_ascii=False),
is_default=body.is_default)
if body.is_default: # 唯一默认
for other in (await db.execute(select(OrderFormat).where(
OrderFormat.company_id == user.company_id))).scalars():
other.is_default = 0
db.add(fmt)
await db.flush()
return ok(data=_to_out(fmt), message="模板已创建")
@router.put("/{format_id}")
async def update_format(format_id: int, body: FormatBody, user: CurrentUser, db: DbSession):
fmt = await db.get(OrderFormat, format_id)
if fmt is None or fmt.company_id != user.company_id:
return fail("模板不存在")
err = _validate_blocks(body.blocks)
if err:
return fail(err)
fmt.name = body.name
fmt.order_type = body.order_type
fmt.blocks = json.dumps(body.blocks, ensure_ascii=False)
if body.is_default:
for other in (await db.execute(select(OrderFormat).where(
OrderFormat.company_id == user.company_id))).scalars():
other.is_default = 0
fmt.is_default = 1
else:
fmt.is_default = 0
return ok(data=_to_out(fmt), message="模板已更新")
@router.delete("/{format_id}")
async def delete_format(format_id: int, user: CurrentUser, db: DbSession):
fmt = await db.get(OrderFormat, format_id)
if fmt is None or fmt.company_id != user.company_id:
return fail("模板不存在")
await db.delete(fmt)
return ok(message="模板已删除")
+2
View File
@@ -7,6 +7,7 @@ from huamenchuang.api import (
contracts,
customers,
optimize,
order_format,
orders,
platform,
quote,
@@ -25,6 +26,7 @@ api_router.include_router(windoor.router)
api_router.include_router(optimize.router)
api_router.include_router(quote.router)
api_router.include_router(script.router)
api_router.include_router(order_format.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)
+2
View File
@@ -19,6 +19,7 @@ from huamenchuang.models.extend import (
Article,
CompanyParam,
Device,
OrderFormat,
PayOrder,
SmsCode,
)
@@ -37,6 +38,7 @@ __all__ = [
"Device",
"MeasureOrder",
"Order",
"OrderFormat",
"OrderLog",
"OrderProcess",
"PayOrder",
+16
View File
@@ -68,6 +68,22 @@ class Article(Base):
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class OrderFormat(Base):
"""订单打印模板(对齐目标站 order_format:可自定义模板区块与默认模板)。"""
__tablename__ = "order_formats"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
name: Mapped[str] = mapped_column(String(64))
order_type: Mapped[int] = mapped_column(default=0) # 0门窗订单 1阳光房订单
# 模板区块 JSON: [{type: title|info|image|table|sign, text?, fields?, kind?}]
blocks: Mapped[str] = mapped_column(Text, default="[]")
is_default: Mapped[int] = mapped_column(default=0)
sort: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class SmsCode(Base):
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
+29
View File
@@ -117,6 +117,35 @@ async def seed() -> None:
await db.commit()
print("价格脚本就绪")
# 7) 打印模板(幂等:按名称去重)
async with SessionLocal() as db:
from huamenchuang.models.extend import OrderFormat
FORMATS = [
("标准生产订单", [
{"type": "title", "text": "生产订单"},
{"type": "info", "fields": ["order_no", "customer_name", "phone", "address", "count", "money"]},
{"type": "image"},
{"type": "table", "kind": "process"},
{"type": "sign"},
], 1),
("生产订单+BOM下料单", [
{"type": "title", "text": "生产订单"},
{"type": "info", "fields": ["order_no", "customer_name", "phone", "address",
"count", "area", "money", "created_at"]},
{"type": "image"},
{"type": "table", "kind": "bom"},
{"type": "table", "kind": "process"},
{"type": "sign"},
], 0),
]
existing = {n for (n,) in (await db.execute(select(OrderFormat.name))).all()}
for i, (name, blocks, is_default) in enumerate(FORMATS):
if name not in existing:
db.add(OrderFormat(name=name, order_type=0, sort=i, is_default=is_default,
blocks=__import__("json").dumps(blocks, ensure_ascii=False)))
await db.commit()
print("打印模板就绪")
if __name__ == "__main__":
asyncio.run(seed())