feat: v0.9 样板数据完整性验证/解析加固/报表统计
- 双源确认样板库完整性: search接口与画图编辑器for_ht目录均为254条, 0遗漏 - 全量体检: 6套样板含7个畸形段(缺ps/pe)导致解析报错 前后端重放/算料统一加固过滤, 254样板0异常/1046窗格 - 报表统计模块(/chart): 近N天订单数金额按日柱图+8项总量+状态分布 - 样板库官方/我的样板双Tab(is_default过滤) - 使用指南接入帮助文章接口(种子8篇), seed分类树幂等修复 - 全接口回归18/18通过
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [2026-09-20 21:00] FIX+FEAT: v0.9 样板数据完整性验证与解析加固——①双源确认样板库完整性: search接口254条 = 画图编辑器for_ht目录内嵌254条, 0遗漏; ②全量体检发现6套样板含7个畸形段(缺ps/pe)导致解析报错, 前后端重放/算料均加固过滤(0异常/1046窗格); ③新增报表统计模块(/chart: 近N天订单数金额按日+8项总量+状态分布, 自绘SVG柱图); ④样板库官方/我的样板双Tab(is_default过滤); ⑤使用指南接入帮助文章接口(种子8篇自撰文章); ⑥seed脚本分类树幂等修复; 全接口回归18/18通过 (Files: src/huamenchuang/core/drawing.py, src/huamenchuang/api/reports.py, src/huamenchuang/api/windoor.py, src/huamenchuang/scripts/*, frontend/src/types/drawing.ts, frontend/src/views/ReportView.vue, frontend/src/views/TemplatesView.vue, frontend/src/views/GuideView.vue)
|
||||||
- [2026-09-20 20:10] FEAT: v0.8 平台扩展——3D展示/效果图渲染/打印中心/下料方案/设备模块/订单分享/验证码登录。①Three.js 3D窗型(重放区域挤出玻璃+型材盒体, 自研轨道控制);②Canvas场景合成效果图(墙面/玻璃渐变/投影, 三场景, 设置持久化到公司参数);③A4打印中心(订单模板+进度+签字栏, 打印CSS隔离);④合同级下料方案(汇总订单算料→FFD→参数持久化, 修正利用率为已排入/已用料);⑤设备激活绑定(序列号+激活码);⑥订单分享(HMAC可逆token+公开只读页);⑦mock短信验证码登录全流程可用(接签名后换provider);⑧帮助文章接口。新增12组API, 前端+6页面共18页 (Files: src/huamenchuang/api/platform.py, src/huamenchuang/models/extend.py, frontend/src/components/WindoorThreed.vue, frontend/src/components/RenderCanvas.vue, frontend/src/views/*, doc/API.md)
|
- [2026-09-20 20:10] FEAT: v0.8 平台扩展——3D展示/效果图渲染/打印中心/下料方案/设备模块/订单分享/验证码登录。①Three.js 3D窗型(重放区域挤出玻璃+型材盒体, 自研轨道控制);②Canvas场景合成效果图(墙面/玻璃渐变/投影, 三场景, 设置持久化到公司参数);③A4打印中心(订单模板+进度+签字栏, 打印CSS隔离);④合同级下料方案(汇总订单算料→FFD→参数持久化, 修正利用率为已排入/已用料);⑤设备激活绑定(序列号+激活码);⑥订单分享(HMAC可逆token+公开只读页);⑦mock短信验证码登录全流程可用(接签名后换provider);⑧帮助文章接口。新增12组API, 前端+6页面共18页 (Files: src/huamenchuang/api/platform.py, src/huamenchuang/models/extend.py, frontend/src/components/WindoorThreed.vue, frontend/src/components/RenderCanvas.vue, frontend/src/views/*, doc/API.md)
|
||||||
- [2026-09-20 19:00] FEAT: v0.7 全功能复刻与前端适配——新增产品价格本/报价单(BJ单号,手动报价+样板算料)/售后单(问题描述必填对齐原站)/量尺单(洞口尺寸清单)/阳光房订单(order_type=1)/个人中心(资料/密码/邀请返利 HMC邀请码) 模块;前端 15 页面(登录注册双Tab/商城/使用指南等)+响应式侧边栏(<900px折叠)+图标全局注册;订单新增order_type字段(含SQLite手工迁移) (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/business.py, src/huamenchuang/api/auth.py, frontend/src/views/*, frontend/src/router/index.ts, doc/API.md)
|
- [2026-09-20 19:00] FEAT: v0.7 全功能复刻与前端适配——新增产品价格本/报价单(BJ单号,手动报价+样板算料)/售后单(问题描述必填对齐原站)/量尺单(洞口尺寸清单)/阳光房订单(order_type=1)/个人中心(资料/密码/邀请返利 HMC邀请码) 模块;前端 15 页面(登录注册双Tab/商城/使用指南等)+响应式侧边栏(<900px折叠)+图标全局注册;订单新增order_type字段(含SQLite手工迁移) (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/business.py, src/huamenchuang/api/auth.py, frontend/src/views/*, frontend/src/router/index.ts, doc/API.md)
|
||||||
- [2026-09-20 11:15] FEAT: v0.6 分割树架构——横梃竖梃完整编辑。①逆向确认原站设计思路: polygon.polygon为基底多边形+mm.sp[]为分割树(polyId指向作用区域), 运行时重放得最终窗格(254样本验证); ②前端 replayShape 分割树重放, 网格渲染真实化(平开1矩形→4-7格); ③中梃编辑: 点击选中(高亮)/拖动移动(line.pt更新→重放,30mm钳制)/删除(移除sp→重放), 加梃指向被点窗格; ④Python侧同语义replay_shape, 报价中梃长度精确到实际作用跨度(2375/2350/1642/979mm); ⑤方案文档§3.3更新原站设计思路与编辑器完成设计 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, src/huamenchuang/core/drawing.py, src/huamenchuang/services/quote_service.py, doc/克隆实施方案.md)
|
- [2026-09-20 11:15] FEAT: v0.6 分割树架构——横梃竖梃完整编辑。①逆向确认原站设计思路: polygon.polygon为基底多边形+mm.sp[]为分割树(polyId指向作用区域), 运行时重放得最终窗格(254样本验证); ②前端 replayShape 分割树重放, 网格渲染真实化(平开1矩形→4-7格); ③中梃编辑: 点击选中(高亮)/拖动移动(line.pt更新→重放,30mm钳制)/删除(移除sp→重放), 加梃指向被点窗格; ④Python侧同语义replay_shape, 报价中梃长度精确到实际作用跨度(2375/2350/1642/979mm); ⑤方案文档§3.3更新原站设计思路与编辑器完成设计 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, src/huamenchuang/core/drawing.py, src/huamenchuang/services/quote_service.py, doc/克隆实施方案.md)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -12,6 +12,7 @@ const router = createRouter({
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', redirect: '/dashboard' },
|
{ path: '', redirect: '/dashboard' },
|
||||||
{ path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } },
|
{ path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } },
|
||||||
|
{ path: 'chart', component: () => import('../views/ReportView.vue'), meta: { title: '报表统计' } },
|
||||||
{ path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } },
|
{ path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } },
|
||||||
{ path: 'sunny', component: () => import('../views/SunnyHouseView.vue'), meta: { title: '阳光房' } },
|
{ path: 'sunny', component: () => import('../views/SunnyHouseView.vue'), meta: { title: '阳光房' } },
|
||||||
{ path: 'quotations', component: () => import('../views/QuotationsView.vue'), meta: { title: '报价单' } },
|
{ path: 'quotations', component: () => import('../views/QuotationsView.vue'), meta: { title: '报价单' } },
|
||||||
|
|||||||
@@ -141,7 +141,11 @@ export function replayShape(shape: FrameShape): {
|
|||||||
regions: Segment[][]
|
regions: Segment[][]
|
||||||
clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[]
|
clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[]
|
||||||
} {
|
} {
|
||||||
const base = (shape.polygon?.polygon ?? []).map(r => r.map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } })))
|
const base = (shape.polygon?.polygon ?? [])
|
||||||
|
.map(r => r
|
||||||
|
.filter(s => s && typeof s === 'object' && s.ps && s.pe && Number.isFinite(s.ps.x) && Number.isFinite(s.pe.x))
|
||||||
|
.map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } })))
|
||||||
|
.filter(r => r.length >= 3)
|
||||||
const regions: Segment[][] = base.map(r => r)
|
const regions: Segment[][] = base.map(r => r)
|
||||||
const clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] = []
|
const clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] = []
|
||||||
for (const sp of (shape.mm?.sp ?? []) as SplitLine[]) {
|
for (const sp of (shape.mm?.sp ?? []) as SplitLine[]) {
|
||||||
|
|||||||
@@ -2,29 +2,25 @@
|
|||||||
<el-card>
|
<el-card>
|
||||||
<el-alert type="info" :closable="false" show-icon
|
<el-alert type="info" :closable="false" show-icon
|
||||||
title="常用操作指南" description="点击左侧菜单进入各功能模块;画布支持滚轮缩放、拖拽平移、双击复位。" />
|
title="常用操作指南" description="点击左侧菜单进入各功能模块;画布支持滚轮缩放、拖拽平移、双击复位。" />
|
||||||
<el-collapse style="margin-top:16px">
|
<el-collapse style="margin-top:16px" v-loading="loading">
|
||||||
<el-collapse-item title="如何创建订单?">
|
<el-collapse-item v-for="a in articles" :key="a.id" :title="a.title">
|
||||||
<p>方式一:合同管理 → 选择合同 → 「下订单」。</p>
|
<p>{{ a.content }}</p>
|
||||||
<p>方式二:订单管理 → 新建订单 → 选择合同与样板(自动带入图纸)→ 创建。</p>
|
|
||||||
</el-collapse-item>
|
|
||||||
<el-collapse-item title="如何编辑窗型?">
|
|
||||||
<p>样板库:点击样板 → 「编辑分格/尺寸」;订单详情 → 「编辑分格/尺寸」。</p>
|
|
||||||
<p>- 改 W/H 输入框或拖拽外框右下角手柄:整体按比例缩放(型材料宽不变)。</p>
|
|
||||||
<p>- +竖梃 / +横梃:点击玻璃区域完成分割。</p>
|
|
||||||
<p>- 点击已有中梃(变蓝)→ 拖动调整位置;「删除梃」移除。</p>
|
|
||||||
<p>- 任何改动后自动重算报价;「保存图纸」写回订单,「另存为我的样板」生成新样板。</p>
|
|
||||||
</el-collapse-item>
|
|
||||||
<el-collapse-item title="如何使用优化下料?">
|
|
||||||
<p>优化下料 → 录入型材需求(长度×数量)与库存原料 → 开始优化。</p>
|
|
||||||
<p>系统按首递减算法输出每根原料的切割组合、余料与利用率。</p>
|
|
||||||
</el-collapse-item>
|
|
||||||
<el-collapse-item title="报价如何计算?">
|
|
||||||
<p>报价 = 外框型材 + 中梃型材(按实际跨度)+ 扇框 + 玻璃面积 + 五金(按扇数)。</p>
|
|
||||||
<p>默认价格可在产品管理中维护,报价时按价格本取价。</p>
|
|
||||||
</el-collapse-item>
|
|
||||||
<el-collapse-item title="订单状态流转">
|
|
||||||
<p>意向单 → 正式单 → 生产中 → 已完工;未完工可取消。生产进度按 下料/组装/玻璃/打胶/五金/质检/完工 七节点推进。</p>
|
|
||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</el-collapse>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const articles = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
articles.value = await api<any[]>('get', '/api/articles')
|
||||||
|
} finally { loading.value = false }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<el-menu :default-active="route.path" router :collapse="collapsed" background-color="#2b3a4d"
|
<el-menu :default-active="route.path" router :collapse="collapsed" background-color="#2b3a4d"
|
||||||
text-color="#bfc9d4" active-text-color="#ffffff">
|
text-color="#bfc9d4" active-text-color="#ffffff">
|
||||||
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon>工作台</el-menu-item>
|
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon>工作台</el-menu-item>
|
||||||
|
<el-menu-item index="/chart"><el-icon><TrendCharts /></el-icon>报表统计</el-menu-item>
|
||||||
<el-menu-item index="/orders"><el-icon><Tickets /></el-icon>订单管理</el-menu-item>
|
<el-menu-item index="/orders"><el-icon><Tickets /></el-icon>订单管理</el-menu-item>
|
||||||
<el-menu-item index="/sunny"><el-icon><OfficeBuilding /></el-icon>阳光房</el-menu-item>
|
<el-menu-item index="/sunny"><el-icon><OfficeBuilding /></el-icon>阳光房</el-menu-item>
|
||||||
<el-menu-item index="/quotations"><el-icon><PriceTag /></el-icon>报价单</el-menu-item>
|
<el-menu-item index="/quotations"><el-icon><PriceTag /></el-icon>报价单</el-menu-item>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="head">
|
||||||
|
<span>经营报表(近 {{ days }} 天)</span>
|
||||||
|
<el-radio-group v-model="days" size="small" @change="load">
|
||||||
|
<el-radio-button :value="7">近7天</el-radio-button>
|
||||||
|
<el-radio-button :value="14">近14天</el-radio-button>
|
||||||
|
<el-radio-button :value="30">近30天</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :xs="12" :sm="8" :md="6" v-for="t in totalsCards" :key="t.label">
|
||||||
|
<div class="stat"><span class="num">{{ t.value }}</span><span class="label">{{ t.label }}</span></div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<h4 class="sec">每日订单量 / 金额</h4>
|
||||||
|
<div class="chart">
|
||||||
|
<div class="col" v-for="d in byDay" :key="d.date">
|
||||||
|
<div class="bars">
|
||||||
|
<div class="bar money" :style="{ height: moneyH(d.money) + 'px' }" :title="`¥${d.money}`" />
|
||||||
|
<div class="bar count" :style="{ height: countH(d.count) + 'px' }" :title="`${d.count} 单`" />
|
||||||
|
</div>
|
||||||
|
<span class="dlabel">{{ d.date.slice(5) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!byDay.length" class="empty">近期暂无订单</div>
|
||||||
|
</div>
|
||||||
|
<div class="legend no-print">
|
||||||
|
<span><i class="dot money" /> 金额(高度按金额比例)</span>
|
||||||
|
<span><i class="dot count" /> 订单数</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="sec">订单状态分布</h4>
|
||||||
|
<el-space wrap>
|
||||||
|
<el-tag v-for="s in statusDist" :key="s.status" style="margin:2px">
|
||||||
|
{{ statusLabel(s.status) }}:{{ s.count }}</el-tag>
|
||||||
|
</el-space>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const days = ref(14)
|
||||||
|
const byDay = ref<any[]>([])
|
||||||
|
const totals = ref<any>({})
|
||||||
|
const statusDist = ref<any[]>([])
|
||||||
|
|
||||||
|
const totalsCards = computed(() => [
|
||||||
|
{ label: '订单总数', value: totals.value.orders ?? 0 },
|
||||||
|
{ label: '意向单', value: totals.value.intentions ?? 0 },
|
||||||
|
{ label: '已完工', value: totals.value.finished ?? 0 },
|
||||||
|
{ label: '报价单', value: totals.value.quotations ?? 0 },
|
||||||
|
{ label: '客户数', value: totals.value.customers ?? 0 },
|
||||||
|
{ label: '合同数', value: totals.value.contracts ?? 0 },
|
||||||
|
{ label: '量尺单', value: totals.value.measures ?? 0 },
|
||||||
|
{ label: '售后处理中', value: totals.value.after_sales ?? 0 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const maxCount = computed(() => Math.max(1, ...byDay.value.map(d => d.count)))
|
||||||
|
const maxMoney = computed(() => Math.max(1, ...byDay.value.map(d => d.money)))
|
||||||
|
const countH = (c: number) => Math.round(c / maxCount.value * 90) + 2
|
||||||
|
const moneyH = (m: number) => Math.round(m / maxMoney.value * 90) + 2
|
||||||
|
|
||||||
|
const statusLabel = (s: number) => ['意向单', '正式单', '生产中', '已完工', '已取消'][s] ?? s
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const d = await api<any>('get', `/api/reports/summary?days=${days.value}`)
|
||||||
|
byDay.value = d.by_day
|
||||||
|
totals.value = d.totals
|
||||||
|
statusDist.value = d.status_dist
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.stat { display: flex; flex-direction: column; align-items: center; padding: 8px 0; }
|
||||||
|
.num { font-size: 26px; font-weight: 700; color: #35506b; }
|
||||||
|
.label { color: #909399; font-size: 12px; }
|
||||||
|
.sec { margin: 18px 0 8px; color: #303133; }
|
||||||
|
.chart { display: flex; gap: 8px; align-items: flex-end; min-height: 120px; padding: 8px; overflow-x: auto; }
|
||||||
|
.col { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||||
|
.bars { display: flex; gap: 3px; align-items: flex-end; height: 96px; }
|
||||||
|
.bar { width: 14px; border-radius: 3px 3px 0 0; }
|
||||||
|
.bar.money { background: #6b8cae; }
|
||||||
|
.bar.count { background: #67c23a; }
|
||||||
|
.dlabel { font-size: 11px; color: #909399; white-space: nowrap; }
|
||||||
|
.legend { display: flex; gap: 16px; font-size: 12px; color: #909399; margin-top: 8px; }
|
||||||
|
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; }
|
||||||
|
.dot.money { background: #6b8cae; }
|
||||||
|
.dot.count { background: #67c23a; }
|
||||||
|
.empty { color: #909399; }
|
||||||
|
</style>
|
||||||
@@ -2,13 +2,17 @@
|
|||||||
<div>
|
<div>
|
||||||
<el-card>
|
<el-card>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
|
<el-radio-group v-model="tab" size="small" @change="load">
|
||||||
|
<el-radio-button value="official">官方样板</el-radio-button>
|
||||||
|
<el-radio-button value="mine">我的样板</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
<el-select v-model="catelogId" placeholder="全部分类" clearable style="width:160px" @change="load">
|
<el-select v-model="catelogId" placeholder="全部分类" clearable style="width:160px" @change="load">
|
||||||
<el-option v-for="c in catelogs" :key="c.id" :label="c.name" :value="c.id" />
|
<el-option v-for="c in catelogs" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="name" placeholder="模板名称" clearable style="width:200px" @keyup.enter="load" />
|
<el-input v-model="name" placeholder="模板名称" clearable style="width:200px" @keyup.enter="load" />
|
||||||
<el-button type="primary" @click="load">查询</el-button>
|
<el-button type="primary" @click="load">查询</el-button>
|
||||||
<div style="flex:1" />
|
<div style="flex:1" />
|
||||||
<span class="count">共 {{ total }} 套官方样板</span>
|
<span class="count">共 {{ total }} 套{{ tab === 'mine' ? '我的' : '官方' }}样板</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-loading="loading" class="grid">
|
<div v-loading="loading" class="grid">
|
||||||
<div v-for="t in rows" :key="t.id" class="item" @click="preview(t)">
|
<div v-for="t in rows" :key="t.id" class="item" @click="preview(t)">
|
||||||
@@ -71,6 +75,7 @@ const editing = ref(false)
|
|||||||
const dirty = ref(false)
|
const dirty = ref(false)
|
||||||
const editRef = ref()
|
const editRef = ref()
|
||||||
const liveTotal = ref<number | null>(null)
|
const liveTotal = ref<number | null>(null)
|
||||||
|
const tab = ref<'official' | 'mine'>('official')
|
||||||
let quoteTimer: number | undefined
|
let quoteTimer: number | undefined
|
||||||
|
|
||||||
// 画布变化(改尺寸/加分格)→ 防抖自动重算报价
|
// 画布变化(改尺寸/加分格)→ 防抖自动重算报价
|
||||||
@@ -90,7 +95,7 @@ function onCanvasChange() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const qs = `page=${page.value}&page_size=${page_size}&name=${name.value}${catelogId.value ? `&catelog_id=${catelogId.value}` : ''}`
|
const qs = `page=${page.value}&page_size=${page_size}&name=${name.value}&is_default=${tab.value === 'official' ? 1 : 0}${catelogId.value ? `&catelog_id=${catelogId.value}` : ''}`
|
||||||
const d = await api<any>('get', `/api/windoor/templates?${qs}`)
|
const d = await api<any>('get', `/api/windoor/templates?${qs}`)
|
||||||
rows.value = d
|
rows.value = d
|
||||||
// 接口无分页元数据时按返回量估计
|
// 接口无分页元数据时按返回量估计
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""报表统计路由(对齐原站 /chart 报表)。"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||||
|
from huamenchuang.core.response import ok
|
||||||
|
from huamenchuang.models.business import AfterSale, MeasureOrder, Quotation
|
||||||
|
from huamenchuang.models.contract import Contract, Customer
|
||||||
|
from huamenchuang.models.order import Order
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/reports", tags=["报表"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def summary(user: CurrentUser, db: DbSession, days: int = 14):
|
||||||
|
"""经营汇总:近 N 天订单数/金额按日 + 各实体总数。"""
|
||||||
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
cid = user.company_id
|
||||||
|
|
||||||
|
# 按日订单数与金额
|
||||||
|
day_rows = (await db.execute(
|
||||||
|
select(func.date(Order.created_at), func.count(), func.sum(Order.money))
|
||||||
|
.where(Order.company_id == cid, Order.created_at >= since)
|
||||||
|
.group_by(func.date(Order.created_at))
|
||||||
|
)).all()
|
||||||
|
by_day = [{"date": str(d), "count": c, "money": float(m or 0)} for d, c, m in day_rows]
|
||||||
|
|
||||||
|
async def count(model, extra=None):
|
||||||
|
conds = [model.company_id == cid]
|
||||||
|
if extra is not None:
|
||||||
|
conds.append(extra)
|
||||||
|
return (await db.execute(select(func.count()).select_from(model).where(*conds))).scalar_one()
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"orders": await count(Order),
|
||||||
|
"intentions": await count(Order, Order.status == 0),
|
||||||
|
"finished": await count(Order, Order.status == 3),
|
||||||
|
"quotations": await count(Quotation),
|
||||||
|
"customers": await count(Customer),
|
||||||
|
"contracts": await count(Contract),
|
||||||
|
"measures": await count(MeasureOrder),
|
||||||
|
"after_sales": await count(AfterSale, AfterSale.status != 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 订单状态分布
|
||||||
|
status_rows = (await db.execute(
|
||||||
|
select(Order.status, func.count()).where(Order.company_id == cid).group_by(Order.status)
|
||||||
|
)).all()
|
||||||
|
status_dist = [{"status": s, "count": c} for s, c in status_rows]
|
||||||
|
|
||||||
|
return ok(data={"by_day": by_day, "totals": totals, "status_dist": status_dist, "days": days})
|
||||||
@@ -10,6 +10,7 @@ from huamenchuang.api import (
|
|||||||
orders,
|
orders,
|
||||||
platform,
|
platform,
|
||||||
quote,
|
quote,
|
||||||
|
reports,
|
||||||
warehouse,
|
warehouse,
|
||||||
windoor,
|
windoor,
|
||||||
)
|
)
|
||||||
@@ -25,3 +26,5 @@ api_router.include_router(quote.router)
|
|||||||
api_router.include_router(warehouse.router)
|
api_router.include_router(warehouse.router)
|
||||||
api_router.include_router(business.router)
|
api_router.include_router(business.router)
|
||||||
api_router.include_router(platform.router)
|
api_router.include_router(platform.router)
|
||||||
|
api_router.include_router(reports.router)
|
||||||
|
api_router.include_router(platform.router)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async def search_templates(
|
|||||||
db: DbSession, user: CurrentUser,
|
db: DbSession, user: CurrentUser,
|
||||||
name: str = Query(default="", max_length=64),
|
name: str = Query(default="", max_length=64),
|
||||||
catelog_id: int | None = None,
|
catelog_id: int | None = None,
|
||||||
|
is_default: int | None = Query(default=None, description="1官方样板 0我的样板"),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = Query(default=50, ge=1, le=200),
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
):
|
):
|
||||||
@@ -41,6 +42,8 @@ async def search_templates(
|
|||||||
conds.append(WindoorTemplate.name.like(f"%{name}%"))
|
conds.append(WindoorTemplate.name.like(f"%{name}%"))
|
||||||
if catelog_id is not None:
|
if catelog_id is not None:
|
||||||
conds.append(WindoorTemplate.catelog_id == catelog_id)
|
conds.append(WindoorTemplate.catelog_id == catelog_id)
|
||||||
|
if is_default is not None:
|
||||||
|
conds.append(WindoorTemplate.is_default == is_default)
|
||||||
stmt = (select(WindoorTemplate).where(*conds)
|
stmt = (select(WindoorTemplate).where(*conds)
|
||||||
.order_by(WindoorTemplate.id).offset((page - 1) * page_size).limit(page_size))
|
.order_by(WindoorTemplate.id).offset((page - 1) * page_size).limit(page_size))
|
||||||
items = list((await db.execute(stmt)).scalars())
|
items = list((await db.execute(stmt)).scalars())
|
||||||
|
|||||||
@@ -90,13 +90,26 @@ def _split_poly(region: list[dict], axis: str, pos: float) -> tuple[list[dict],
|
|||||||
return to_segs(poly_a), to_segs(poly_b)
|
return to_segs(poly_a), to_segs(poly_b)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_region(region: list) -> list[dict]:
|
||||||
|
"""过滤畸形段(缺 ps/pe 或类型不对),保证重放/渲染不抛错。"""
|
||||||
|
out = []
|
||||||
|
for s in region:
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
ps, pe = s.get("ps"), s.get("pe")
|
||||||
|
if isinstance(ps, dict) and isinstance(pe, dict) and "x" in ps and "y" in ps and "x" in pe and "y" in pe:
|
||||||
|
out.append({"ps": dict(ps), "pe": dict(pe)})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def replay_shape(shape: dict) -> list[list[dict]]:
|
def replay_shape(shape: dict) -> list[list[dict]]:
|
||||||
"""分割树重放: 基底多边形 + mm.sp[](polyId.idx 指向作用区域) → 最终窗格列表。
|
"""分割树重放: 基底多边形 + mm.sp[](polyId.idx 指向作用区域) → 最终窗格列表。
|
||||||
|
|
||||||
与前端 replayShape 同语义: idx 越界取 0; 退化/非凸跳过。
|
与前端 replayShape 同语义: idx 越界取 0; 退化/非凸跳过; 畸形段过滤。
|
||||||
"""
|
"""
|
||||||
base = shape.get("polygon", {}).get("polygon", []) or []
|
base = shape.get("polygon", {}).get("polygon", []) or []
|
||||||
regions = [[{"ps": dict(s["ps"]), "pe": dict(s["pe"])} for s in r] for r in base]
|
regions = [_clean_region(r) for r in base]
|
||||||
|
regions = [r for r in regions if r]
|
||||||
if not regions:
|
if not regions:
|
||||||
return regions
|
return regions
|
||||||
for sp in (shape.get("mm") or {}).get("sp", []) or []:
|
for sp in (shape.get("mm") or {}).get("sp", []) or []:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""种子文章(帮助中心内容,自撰)。"""
|
||||||
|
|
||||||
|
ARTICLES = [
|
||||||
|
("常见问题解答", "如何更改标线尺寸?", "在画布编辑模式中,直接修改顶部 W/H 输入框,或拖拽外框右缘/下缘/右下角手柄;所有尺寸标注会自动跟随几何更新。"),
|
||||||
|
("常见问题解答", "如何绘制横竖中梃?", "进入编辑模式后点击「+竖梃」或「+横梃」,再点击目标玻璃区域即可完成分割;点击已有中梃可选中并拖动调整位置,「删除梃」可移除。"),
|
||||||
|
("常见问题解答", "如何把样板改成自己的?", "样板库 → 点击样板 → 编辑分格/尺寸 → 「另存为我的样板」,原图板不受影响。"),
|
||||||
|
("常见问题解答", "报价是如何计算的?", "报价 = 外框 + 中梃(按实际跨度)+ 扇框 + 玻璃面积 + 五金(按扇数),价格取自产品管理中的价格本。"),
|
||||||
|
("功能教程", "订单全流程", "合同管理创建意向单 → 转正式单 → 下订单 → 订单详情中推进生产进度(下料/组装/玻璃/打胶/五金/质检/完工)→ 打印中心输出生产单。"),
|
||||||
|
("功能教程", "优化下料使用", "录入订单的型材需求与仓库原料,系统按首递减算法输出切割组合、余料与利用率;也可在「下料方案」按合同一键汇总。"),
|
||||||
|
("功能教程", "3D 展示与效果图", "「效果图渲染」可在场景中预览窗型并调整墙面/玻璃颜色;「3D 展示」提供可旋转的立体视图。"),
|
||||||
|
("功能教程", "分享订单给客户", "订单管理 → 「分享」→ 复制链接发给客户,客户免登录即可查看订单摘要与图纸。"),
|
||||||
|
]
|
||||||
@@ -27,9 +27,9 @@ async def seed() -> None:
|
|||||||
# 2) 分类树(来自 default_windoor_catelog.json,取目标站原始 id 便于对照)
|
# 2) 分类树(来自 default_windoor_catelog.json,取目标站原始 id 便于对照)
|
||||||
cat_file = DATA / "api-samples" / "default_windoor_catelog.json"
|
cat_file = DATA / "api-samples" / "default_windoor_catelog.json"
|
||||||
if cat_file.exists():
|
if cat_file.exists():
|
||||||
|
from sqlalchemy import select as _sel
|
||||||
cats = json.load(open(cat_file, encoding="utf-8"))["data"]
|
cats = json.load(open(cat_file, encoding="utf-8"))["data"]
|
||||||
existing = {c.id for c in (await db.execute(
|
existing = set((await db.execute(_sel(WindoorCatelog.id))).scalars())
|
||||||
__import__("sqlalchemy").select(WindoorCatelog.id))).scalars()}
|
|
||||||
for top in cats:
|
for top in cats:
|
||||||
if top["id"] not in existing:
|
if top["id"] not in existing:
|
||||||
db.add(WindoorCatelog(id=top["id"], name=top["name"], fid=0, company_id=1))
|
db.add(WindoorCatelog(id=top["id"], name=top["name"], fid=0, company_id=1))
|
||||||
@@ -71,6 +71,17 @@ async def seed() -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
print("种子数据完成")
|
print("种子数据完成")
|
||||||
|
|
||||||
|
# 4) 帮助文章(幂等:按标题去重)
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
from huamenchuang.models.extend import Article
|
||||||
|
from huamenchuang.scripts.article_seed_data import ARTICLES
|
||||||
|
existing = {t for (t,) in (await db.execute(select(Article.title))).all()}
|
||||||
|
for i, (cat, title, content) in enumerate(ARTICLES):
|
||||||
|
if title not in existing:
|
||||||
|
db.add(Article(title=title, category=cat, content=content, sort=i))
|
||||||
|
await db.commit()
|
||||||
|
print("帮助文章就绪")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(seed())
|
asyncio.run(seed())
|
||||||
|
|||||||
Reference in New Issue
Block a user