feat: 前后端全量开发 v0.2

后端:
- 客户模块 CRUD+搜索 (api/services/repositories 分层)
- 合同模块: FG单号规则/意向单转正式单/订单创建自动快照客户信息
- 优化下料: FFD首递减装箱算法(利用率统计/余料/未排入清单)
前端 (Vue3+TS+Element Plus):
- 7页面: 登录/工作台/订单中心/合同/客户/样板库/优化下料
- 自研二维窗型画布渲染器: 解析drawing_data渲染玻璃区域/外框/
  扇开向标记/尺寸标注, 支持缩放平移复位
- 样板库与订单联动(用此样板下单自动带图纸)
- axios统一envelope拦截/登录守卫/付费墙提示
工程:
- SPA history 回退路由, 构建产物挂载后端
- 全接口回归 6/6 通过
This commit is contained in:
HHY
2026-09-20 08:46:04 -07:00
parent d3e6de585c
commit f7a555dd6c
35 changed files with 3702 additions and 10 deletions
+3
View File
@@ -15,3 +15,6 @@ wheels/
# Logs # Logs
*.log *.log
# Frontend
node_modules/
+2
View File
@@ -1,8 +1,10 @@
# CHANGELOG # CHANGELOG
- [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 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) - [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)
- [2026-09-20 06:41] FEAT: 完成目标站逆向采集——样板库 254 套模板 drawing_data 全量采集(254/254)、16 个核心接口响应样本、约 300 条接口清单;输出《克隆实施方案》《API 接口文档》(Files: doc/克隆实施方案.md, doc/API.md, data/api-endpoint-inventory.txt, data/api-samples/*, data/windoor-drawings/*) - [2026-09-20 06:41] FEAT: 完成目标站逆向采集——样板库 254 套模板 drawing_data 全量采集(254/254)、16 个核心接口响应样本、约 300 条接口清单;输出《克隆实施方案》《API 接口文档》(Files: doc/克隆实施方案.md, doc/API.md, data/api-endpoint-inventory.txt, data/api-samples/*, data/windoor-drawings/*)
- [2026-09-20 06:41] DOCS: 初始化 README 项目导航 (Files: README.md) - [2026-09-20 06:41] DOCS: 初始化 README 项目导航 (Files: README.md)
+10 -4
View File
@@ -5,8 +5,9 @@
## 当前进度 ## 当前进度
- ✅ 目标站逆向与数据采集(样板库 254 套全量 + 二维平面图 254 张 + 接口全量留档) - ✅ 目标站逆向与数据采集(样板库 254 套全量 + 二维平面图 254 张 + 接口全量留档)
- ✅ 后端 v0.1:认证 / 订单模块(状态机+生产进度+审计日志)/ 样板库 API - ✅ 后端 v0.2:认证 / 订单(状态机+生产进度+审计日志)/ 客户 / 合同(意向单→正式单)/ 样板库 / 优化下料(FFD)
- ⏳ 前端 Vue3 与画布编辑器(规划中,见方案 §3.3) - ✅ 前端 v0.2:Vue3 + Element Plus,7 个页面,自研二维窗型画布渲染器(可渲染全部 254 套样板)
- ⏳ 报价引擎 / 打印模板 / 微信生态(规划中,见方案 §6 里程碑)
## 文档导航 ## 文档导航
@@ -31,11 +32,16 @@ frontend/ 前端(Vue3 + TS,规划中)
## 快速开始 ## 快速开始
```bash ```bash
uv sync # 安装依赖 uv sync # 后端依赖
npm --prefix frontend install # 前端依赖
uv run python -m huamenchuang.scripts.seed # 管理员 + 254 套样板入库 uv run python -m huamenchuang.scripts.seed # 管理员 + 254 套样板入库
uv run uvicorn huamenchuang.main:app --reload # 启动 → http://localhost:8000/docs npm --prefix frontend run build # 构建前端到 frontend/dist(后端自动挂载)
uv run uvicorn huamenchuang.main:app --reload # 启动 → http://localhost:8000
``` ```
打开 http://localhost:8000 即为完整系统(SPA 由后端服务,深链接自动回退)。
前端开发热更新:`npm --prefix frontend run dev`(5173 端口,/api 代理到 8000)。
管理员账号(开发):`13800000000 / 123456`。 管理员账号(开发):`13800000000 / 123456`。
环境变量(均可选):`HMC_DATABASE_URL`(默认 SQLite,可切 MySQL)、`HMC_JWT_SECRET`(生产必设)、`HMC_ENABLED_MODULES`(套餐模块开关)。 环境变量(均可选):`HMC_DATABASE_URL`(默认 SQLite,可切 MySQL)、`HMC_JWT_SECRET`(生产必设)、`HMC_ENABLED_MODULES`(套餐模块开关)。
+42 -1
View File
@@ -231,7 +231,48 @@
| 接口文档 | GET | `/docs` | FastAPI 自动生成 OpenAPI | | 接口文档 | GET | `/docs` | FastAPI 自动生成 OpenAPI |
| 平面图静态 | GET | `/static/windoor-images/{id}.png` | 254 张采集平面图(开发参照,上线前须替换为自绘素材) | | 平面图静态 | GET | `/static/windoor-images/{id}.png` | 254 张采集平面图(开发参照,上线前须替换为自绘素材) |
### 12.5 种子数据 ### 12.5 客户模块 `/api/customers`(v0.2 新增)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 新增客户 | POST | `/api/customers` | `{name(必填), phone, address, note, salesmen}` | |
| 客户列表 | GET | `/api/customers?page=1&page_size=15&keyword=` | query | 模糊匹配姓名/电话/地址 |
| 编辑客户 | PUT | `/api/customers/{id}` | 同新增 | |
| 删除客户 | DELETE | `/api/customers/{id}` | 路径 | |
### 12.6 合同模块 `/api/contracts`(v0.2 新增)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 创建合同 | POST | `/api/contracts` | `{customer_id, customer_name, phone, address, area, count, money, note}` | 合同号 `FG+YYYYMMDD+3位流水`,默认意向单 |
| 合同列表 | GET | `/api/contracts?page=1&keyword=&status=` | query | 响应 `data.contracts` 分页(对齐目标站结构) |
| 编辑合同 | PUT | `/api/contracts/{id}` | 同创建 | |
| **意向单转正式** | POST | `/api/contracts/convert` | `{contract_id}` | 仅意向单可转,重复转换 code=0 |
| 删除合同 | DELETE | `/api/contracts/{id}` | 路径 | |
**合同→订单联动**:创建订单传 `contract_id` 时,后端自动快照合同的客户名/电话/地址到订单(客户资料后续变更不影响订单)。
### 12.7 优化下料 `/api/optimize1d`(v0.2 新增,纯计算无状态)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 一维优化 | 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,构建产物挂载于后端)
| 路由 | 页面 | 功能 |
|---|---|---|
| `/login` | 登录 | JWT 登录 |
| `/dashboard` | 工作台 | 订单/意向单/客户/合同统计 + 快捷入口 |
| `/orders` | 订单中心 | 搜索/新建(选合同+样板自动带图纸)/详情抽屉(**画布渲染 drawing_data**)+状态机操作+生产进度更新 |
| `/contracts` | 合同管理 | 列表/新建(选客户带出信息)/意向单转正式/一键下订单 |
| `/customers` | 客户管理 | CRUD + 搜索 |
| `/templates` | 样板库 | 254 套样板网格(平面图) + 分类筛选 + 点击预览(**画布渲染**) + 用此样板下单 |
| `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) |
**画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 扇开向三角标记(od 角度) → 总宽高尺寸标注;支持滚轮缩放、拖拽平移、双击复位。
### 12.9 种子数据
```bash ```bash
uv sync # 安装依赖 uv sync # 安装依赖
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>华门窗云平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1886
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "huamenchuang-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.0",
"element-plus": "^2.9.0",
"@element-plus/icons-vue": "^2.3.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
+8
View File
@@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif; }
</style>
+43
View File
@@ -0,0 +1,43 @@
/** axios 实例:自动携带 token、统一处理 401/付费墙 */
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
export const http = axios.create({ baseURL: '/', timeout: 30000 })
http.interceptors.request.use(cfg => {
const token = localStorage.getItem('hmc_token')
if (token) cfg.headers.Authorization = `Bearer ${token}`
return cfg
})
http.interceptors.response.use(
res => {
const d = res.data
if (d && typeof d === 'object' && 'code' in d) {
if (d.code === 1) return res
if (String(d.code) === '10086') {
ElMessage.warning(d.message ?? '该功能需要购买解锁')
return Promise.reject(new Error(d.message))
}
if (Number(d.code) === 401) {
localStorage.removeItem('hmc_token')
router.push('/login')
return Promise.reject(new Error(d.message))
}
ElMessage.error(d.message ?? '操作失败')
return Promise.reject(new Error(d.message))
}
return res
},
err => {
ElMessage.error(err.message ?? '网络错误')
return Promise.reject(err)
},
)
/** 提取 envelope 的 data */
export async function api<T = any>(method: string, url: string, body?: object): Promise<T> {
const res = await http.request({ method, url, data: body })
return res.data.data as T
}
+232
View File
@@ -0,0 +1,232 @@
<template>
<div class="canvas-wrap" ref="wrapRef">
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" />
<div class="hud">
<span v-if="info">{{ info }}</span>
<el-button size="small" text @click="reset">重置视图</el-button>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 二维窗型画布渲染器(自研实现)
* 读取 shapeManager 序列化的 drawing_data:Frame→玻璃区域多边形 + 扇开向 + 尺寸标注
* 支持滚轮缩放、拖拽平移、双击复位
*/
import { onMounted, ref, watch } from 'vue'
import {
bboxOf, parseDrawing, regionPoints,
type DrawingData, type FrameShape, type Pt, type SashShape,
} from '../types/drawing'
const props = defineProps<{ drawingData: string | object }>()
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const info = ref('')
let view = { scale: 1, cx: 0, cy: 0 } // cx/cy=模型坐标中心
let drag: { x: number; y: number } | null = null
let dd: DrawingData | null = null
let modelBox = { x0: 0, y0: 0, x1: 100, y1: 100 }
function render() {
const cv = cvRef.value, wrap = wrapRef.value
if (!cv || !wrap) return
const dpr = window.devicePixelRatio || 1
cv.width = wrap.clientWidth * dpr
cv.height = wrap.clientHeight * dpr
const ctx = cv.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, cv.width, cv.height)
if (!dd) return
const W = wrap.clientWidth, H = wrap.clientHeight
// 自适应:模型坐标 → 屏幕(Y 轴翻转)
const pad = 48
const bw = modelBox.x1 - modelBox.x0, bh = modelBox.y1 - modelBox.y0
const fit = Math.min((W - pad * 2) / (bw || 1), (H - pad * 2) / (bh || 1))
const scale = fit * view.scale
const midX = (modelBox.x0 + modelBox.x1) / 2, midY = (modelBox.y0 + modelBox.y1) / 2
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy
// 全局样式
const glass = '#d3ddda', glassAlpha = 'rgba(211,221,218,0.6)'
const profile = '#5c6166'
ctx.lineJoin = 'round'
for (const shape of dd.sm) drawFrame(ctx, shape, tx, ty, { glass, glassAlpha, profile })
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
}
function drawFrame(
ctx: CanvasRenderingContext2D, shape: FrameShape,
tx: (x: number) => number, ty: (y: number) => number,
color: { glass: string; glassAlpha: string; profile: string },
) {
const regionsRaw = shape.polygon?.polygon ?? []
const regions = regionsRaw.map(regionPoints).filter(pts => pts.length >= 3)
const cm = shape.cm ?? {}
// ---- 玻璃区域 ----
for (const pts of regions) {
ctx.beginPath()
pts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
ctx.closePath()
ctx.fillStyle = cm.g ?? color.glassAlpha
ctx.fill()
ctx.strokeStyle = cm.bn ?? color.profile
ctx.lineWidth = 1.6
ctx.stroke()
// 内描边示意型材厚度
ctx.strokeStyle = 'rgba(255,255,255,0.65)'
ctx.lineWidth = 0.8
ctx.stroke()
}
// ---- 外框(全部区域的联合包围盒,逐段加粗)----
if (regions.length) {
const all = regions.flat()
const bb = bboxOf(all)
const x0 = tx(bb.x0), y0 = ty(bb.y1), x1 = tx(bb.x1), y1 = ty(bb.y0)
const ew = shape.fm?.ew ?? [60, 60, 60, 60]
const avg = ew.reduce((a, b) => a + b, 0) / ew.length
ctx.strokeStyle = cm.bn ?? color.profile
ctx.lineWidth = 3.2
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0)
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
ctx.lineWidth = 1
const inset = Math.max(2.5, avg * 0.018)
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
void glass // 保留引用
}
// ---- 扇(开向标记)----
for (const sub of (shape.sm ?? []) as SashShape[]) {
const bd = sub.bd
if (!bd || bd.hidden) continue
const bx0 = tx(Math.min(bd.from.x, bd.to.x)), bx1 = tx(Math.max(bd.from.x, bd.to.x))
const by0 = ty(Math.max(bd.from.y, bd.to.y)), by1 = ty(Math.min(bd.from.y, bd.to.y))
const w = bx1 - bx0, h = by1 - by0
if (w < 4 || h < 4) continue
ctx.save()
// 扇框(虚线内框)
ctx.setLineDash([5, 4])
ctx.strokeStyle = 'rgba(70,76,82,0.9)'
ctx.lineWidth = 1.3
ctx.strokeRect(bx0 + 2, by0 + 2, w - 4, h - 4)
ctx.setLineDash([])
// 开向三角:od 为角度(度),屏幕系 y 翻转
const ang = ((sub.od ?? 0) * Math.PI) / 180
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
const L = Math.min(w, h) * 0.32
const ax = cx + Math.cos(ang) * L, ay = cy - Math.sin(ang) * L
// 垂直方向的两翼
const wx = Math.cos(ang + Math.PI / 2) * L * 0.38
const wy = -Math.sin(ang + Math.PI / 2) * L * 0.38
ctx.beginPath()
ctx.moveTo(ax, ay)
ctx.lineTo(cx - wx, cy - wy)
ctx.lineTo(cx + wx, cy + wy)
ctx.closePath()
ctx.fillStyle = 'rgba(70,76,82,0.25)'
ctx.fill()
ctx.strokeStyle = 'rgba(70,76,82,0.95)'
ctx.lineWidth = 1.4
ctx.stroke()
if (sub.isDoor) {
ctx.fillStyle = 'rgba(70,76,82,0.85)'
ctx.font = '10px sans-serif'
ctx.fillText('门', cx - 6, by1 - 6)
}
ctx.restore()
}
// ---- 总尺寸标注(宽/高)----
if (regions.length) {
const all = regions.flat()
const bb = bboxOf(all)
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0)
const wmm = Math.round(bb.x1 - bb.x0), hmm = Math.round(bb.y1 - bb.y0)
ctx.fillStyle = '#7a8087'
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${wmm} mm`, (x0 + x1) / 2, y1 + 16)
ctx.save()
ctx.translate(x0 - 14, (y0 + y1) / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillText(`${hmm} mm`, 0, 0)
ctx.restore()
ctx.strokeStyle = 'rgba(122,128,135,0.6)'
ctx.lineWidth = 1
// 尺寸线
ctx.beginPath()
ctx.moveTo(x0, y1 + 6); ctx.lineTo(x1, y1 + 6)
ctx.moveTo(x0 - 6, y0); ctx.lineTo(x0 - 6, y1)
ctx.stroke()
}
}
function computeModelBox() {
if (!dd) return
const pts: Pt[] = []
for (const s of dd.sm) {
for (const r of (s.polygon?.polygon ?? [])) {
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
}
// 扇包围盒也计入
for (const sub of (s.sm ?? []) as SashShape[]) {
if (sub.bd) { pts.push(sub.bd.from, sub.bd.to) }
}
}
if (!pts.length) { modelBox = { x0: 0, y0: 0, x1: 1000, y1: 1000 }; return }
modelBox = bboxOf(pts)
}
function reset() {
view = { scale: 1, cx: 0, cy: 0 }
render()
}
function onWheel(e: WheelEvent) {
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12
view.scale = Math.min(40, Math.max(0.2, view.scale * factor))
render()
}
function onDown(e: MouseEvent) { drag = { x: e.clientX, y: e.clientY } }
function onMove(e: MouseEvent) {
if (!drag) return
view.cx += e.clientX - drag.x
view.cy += e.clientY - drag.y
drag = { x: e.clientX, y: e.clientY }
render()
}
function onUp() { drag = null }
onMounted(() => {
watch(() => props.drawingData, () => {
try {
dd = props.drawingData ? parseDrawing(props.drawingData) : null
computeModelBox()
reset()
} catch (e) {
info.value = '数据解析失败'
dd = null
render()
}
}, { immediate: true })
window.addEventListener('resize', render)
})
</script>
<style scoped>
.canvas-wrap { position: relative; width: 100%; height: 100%; min-height: 320px;
background: repeating-conic-gradient(#f7f8f9 0% 25%, #eef0f2 0% 50%) 50%/24px 24px; }
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
canvas:active { cursor: grabbing; }
.hud { position: absolute; right: 10px; top: 10px; display: flex; gap: 8px; align-items: center;
background: rgba(255,255,255,0.85); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
</style>
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
+31
View File
@@ -0,0 +1,31 @@
/** 路由:登录守卫 + 主布局 */
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('../views/LoginView.vue') },
{
path: '/',
component: () => import('../views/LayoutView.vue'),
children: [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } },
{ path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } },
{ 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: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
],
},
],
})
router.beforeEach(to => {
const auth = useAuthStore()
if (to.path !== '/login' && !auth.isLogin) return '/login'
return true
})
export default router
+24
View File
@@ -0,0 +1,24 @@
/** 认证状态 */
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('hmc_token') ?? '',
nickname: localStorage.getItem('hmc_nickname') ?? '',
}),
getters: { isLogin: s => !!s.token },
actions: {
setSession(token: string, nickname: string) {
this.token = token
this.nickname = nickname
localStorage.setItem('hmc_token', token)
localStorage.setItem('hmc_nickname', nickname)
},
logout() {
this.token = ''
this.nickname = ''
localStorage.removeItem('hmc_token')
localStorage.removeItem('hmc_nickname')
},
},
})
+106
View File
@@ -0,0 +1,106 @@
/** drawing_data 数据模型类型(与采集的 shapeManager 序化格式同构) */
export interface Pt { x: number; y: number; name?: string }
export interface Vec { x: number; y: number; name?: string }
export interface Segment { ps: Pt; pe: Pt; name?: string }
/** 中梃分割线 */
export interface SplitLine {
line: { pt: Pt; norm: Vec; name?: string }
polyId?: { idx: number; pos: number }
alignType?: number
equalSplit?: number
kw?: boolean // 竖撑
ws?: boolean // 窗撑
reinforced?: boolean
db?: boolean // hidden/disabled
}
/** 尺寸标注 */
export interface Dim {
at: number; ds: boolean
ov?: Vec
eidx?: number // 绑定外框边
midx?: number // 绑定中梃
name?: string
type?: 'line' | 'mullion' | string
}
export interface FrameModel {
ew?: [number, number, number, number] // 四边料宽
vs?: Pt[]
db?: unknown[]
ejw?: number[]
pew?: unknown[]
ast?: unknown[]
}
export interface ColorMap {
g?: string // 玻璃填充
bn?: string // 边框
hd?: string // 高光
ben?: string
}
export interface SashShape {
type: 'Sash' | string
bd?: { from: Pt; to: Pt; hidden?: boolean } // 扇所在区域包围盒
od?: number // 开向角度
opened?: boolean
wtf?: boolean // 带转框
isDoor?: boolean
polyId?: { idx: number; pos: number }
enabled?: boolean
rid?: string
[k: string]: unknown
}
export interface FrameShape {
type: 'Frame' | string
polygon?: {
type?: string
polygon?: Segment[][] // 分割后的玻璃区域(每区域为闭合段列表)
spLine?: SplitLine
ep?: string[]
virtual?: boolean
}
fm?: FrameModel
mm?: { sp: SplitLine[]; dbs?: unknown[]; fls?: unknown[]; phs?: unknown[]; lpfs?: unknown[] }
dm?: { dims: Dim[] }
cm?: ColorMap
sm?: SashShape[] // 子形状(扇)
ps?: Record<string, number>
uid?: string
titl?: string
[k: string]: unknown
}
export interface DrawingData {
s?: { cs?: number; cjs?: number }
cp?: unknown[]
sm: FrameShape[]
epi?: unknown[]
sbf?: unknown[]
wlc?: string
wall?: unknown[]
scale?: number
note?: unknown[]
}
/** 解析 drawing_data(兼容对象或 JSON 字符串) */
export function parseDrawing(raw: string | object): DrawingData {
const dd = typeof raw === 'string' ? JSON.parse(raw) : raw
return dd as DrawingData
}
/** 区域多边形 → 点列(段首点顺序连接) */
export function regionPoints(region: Segment[]): Pt[] {
return region.map(seg => ({ x: seg.ps.x, y: seg.ps.y }))
}
/** 点列包围盒 */
export function bboxOf(points: Pt[]): { x0: number; y0: number; x1: number; y1: number } {
const xs = points.map(p => p.x)
const ys = points.map(p => p.y)
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }
}
+131
View File
@@ -0,0 +1,131 @@
<template>
<el-card>
<div class="toolbar">
<el-input v-model="keyword" placeholder="合同号/客户" clearable style="width:220px" @keyup.enter="load" />
<el-select v-model="status" placeholder="全部状态" clearable style="width:130px">
<el-option :value="0" label="意向单" /><el-option :value="1" label="正式单" />
<el-option :value="2" label="已完成" /><el-option :value="3" label="已作废" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<div style="flex:1" />
<el-button type="primary" plain @click="openCreate">新建合同</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="contract_no" label="合同号" width="160" />
<el-table-column prop="customer_name" label="客户" min-width="110" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="count" label="数量" width="70" />
<el-table-column prop="money" label="金额" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'info' : row.status === 1 ? 'primary' : 'success'" size="small">
{{ row.status_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 0" size="small" type="primary"
@click="convert(row)">转正式单</el-button>
<el-button size="small" @click="createOrder(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-dialog v-model="createVisible" title="新建合同" width="520px">
<el-form :model="form" label-width="80px">
<el-form-item label="客户">
<el-select v-model="form.customer_id" filterable clearable style="width:100%"
placeholder="选择客户(自动带出电话地址)" @change="onCustomer">
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="客户名"><el-input v-model="form.customer_name" /></el-form-item>
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></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.money" :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="createVisible = 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 { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const router = useRouter()
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const page_size = 15
const keyword = ref('')
const status = ref<number | undefined>()
const loading = ref(false)
const createVisible = ref(false)
const customerOptions = ref<any[]>([])
const form = reactive({ customer_id: undefined as number | undefined, customer_name: '', phone: '', address: '', count: 0, money: 0, note: '' })
async function load() {
loading.value = true
try {
const d = await api<any>('get', `/api/contracts?page=${page.value}&page_size=${page_size}&keyword=${keyword.value}${status.value !== undefined ? `&status=${status.value}` : ''}`)
rows.value = d.contracts.data
total.value = d.contracts.total
} finally { loading.value = false }
}
function openCreate() {
Object.assign(form, { customer_id: undefined, customer_name: '', phone: '', address: '', count: 0, money: 0, note: '' })
api<any>('get', '/api/customers?page=1&page_size=100').then(d => (customerOptions.value = d.data))
createVisible.value = true
}
function onCustomer(cid: number | undefined) {
const c = customerOptions.value.find(x => x.id === cid)
if (c) { form.customer_name = c.name; form.phone = c.phone; form.address = c.address }
}
async function save() {
await api('post', '/api/contracts', form)
ElMessage.success('合同已创建')
createVisible.value = false
load()
}
async function convert(row: any) {
await api('post', '/api/contracts/convert', { contract_id: row.id })
ElMessage.success('已转为正式单')
load()
}
async function createOrder(row: any) {
await ElMessageBox.confirm(`为合同 ${row.contract_no} 创建订单?`, '提示')
await api('post', '/api/orders', { contract_id: row.id, money: row.money, count: row.count })
ElMessage.success('订单已创建,可到订单管理查看')
router.push('/orders')
}
async function remove(row: any) {
await ElMessageBox.confirm(`确定删除合同 ${row.contract_no}?`, '提示', { type: 'warning' })
await api('delete', `/api/contracts/${row.id}`)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
</style>
+89
View File
@@ -0,0 +1,89 @@
<template>
<el-card>
<div class="toolbar">
<el-input v-model="keyword" placeholder="姓名/电话/地址" clearable style="width:240px" @keyup.enter="load" />
<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="id" label="ID" width="70" />
<el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column prop="phone" label="电话" width="140" />
<el-table-column prop="address" label="地址" min-width="180" />
<el-table-column prop="salesmen" label="业务员" width="100" />
<el-table-column prop="created_at" label="创建时间" width="170" />
<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-dialog v-model="editVisible" :title="form.id ? '编辑客户' : '新增客户'" width="480px">
<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-input v-model="form.phone" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></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-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 loading = ref(false)
const editVisible = ref(false)
const form = reactive({ id: 0, name: '', phone: '', address: '', note: '' })
async function load() {
loading.value = true
try {
const d = await api<any>('get', `/api/customers?page=${page.value}&page_size=${page_size}&keyword=${keyword.value}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
function openEdit(row?: any) {
Object.assign(form, { id: row?.id ?? 0, name: row?.name ?? '', phone: row?.phone ?? '', address: row?.address ?? '', note: row?.note ?? '' })
editVisible.value = true
}
async function save() {
if (form.id) await api('put', `/api/customers/${form.id}`, form)
else await api('post', '/api/customers', form)
ElMessage.success('已保存')
editVisible.value = false
load()
}
async function remove(row: any) {
await ElMessageBox.confirm(`确定删除客户「${row.name}」?`, '提示', { type: 'warning' })
await api('delete', `/api/customers/${row.id}`)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div>
<el-row :gutter="16">
<el-col :span="6" v-for="card in cards" :key="card.label">
<el-card shadow="hover">
<div class="stat"><span class="num">{{ card.value }}</span><span class="label">{{ card.label }}</span></div>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top:16px">
<template #header>快捷入口</template>
<el-space wrap>
<el-button type="primary" @click="$router.push('/templates')">浏览样板库</el-button>
<el-button @click="$router.push('/orders')">新建订单</el-button>
<el-button @click="$router.push('/contracts')">录入合同</el-button>
<el-button @click="$router.push('/optimize')">优化下料</el-button>
</el-space>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api'
const cards = ref([
{ label: '订单总数', value: '-' },
{ label: '意向单', value: '-' },
{ label: '客户数', value: '-' },
{ label: '合同数', value: '-' },
])
onMounted(async () => {
const [orders, customers, contracts] = await Promise.all([
api<any>('post', '/api/orders/search', { page: 1, page_size: 1 }),
api<any>('get', '/api/customers?page=1&page_size=1'),
api<any>('get', '/api/contracts?page=1&page_size=1'),
])
cards.value[0].value = orders.total ?? 0
cards.value[2].value = customers.total ?? 0
cards.value[3].value = contracts.contracts?.total ?? 0
const intentions = await api<any>('post', '/api/orders/search', { page: 1, page_size: 1, status: 0 })
cards.value[1].value = intentions.total ?? 0
})
</script>
<style scoped>
.stat { display: flex; flex-direction: column; align-items: center; padding: 8px 0; }
.num { font-size: 30px; font-weight: 700; color: #35506b; }
.label { color: #909399; font-size: 13px; margin-top: 4px; }
</style>
+50
View File
@@ -0,0 +1,50 @@
<template>
<el-container class="layout">
<el-aside width="200px">
<div class="logo">华门窗</div>
<el-menu :default-active="route.path" router background-color="#2b3a4d" text-color="#bfc9d4"
active-text-color="#ffffff">
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon>工作台</el-menu-item>
<el-menu-item index="/orders"><el-icon><Tickets /></el-icon>订单管理</el-menu-item>
<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="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<span class="title">{{ route.meta.title }}</span>
<el-dropdown @command="cmd => cmd === 'logout' && doLogout()">
<span class="user"><el-icon><UserFilled /></el-icon>{{ auth.nickname || '用户' }}</span>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</el-header>
<el-main><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
function doLogout() { auth.logout(); router.push('/login') }
</script>
<style scoped>
.layout { height: 100vh; }
.el-aside { background: #2b3a4d; }
.logo { color: #fff; font-size: 20px; font-weight: 700; text-align: center; padding: 20px 0; letter-spacing: 4px; }
.el-menu { border-right: none; }
.header { display: flex; justify-content: space-between; align-items: center;
background: #fff; border-bottom: 1px solid #e4e7ed; }
.title { font-size: 16px; font-weight: 600; color: #303133; }
.user { cursor: pointer; color: #606266; display: inline-flex; gap: 6px; align-items: center; }
.el-main { background: #f2f4f7; padding: 16px; }
</style>
+54
View File
@@ -0,0 +1,54 @@
<template>
<div class="login-page">
<el-card class="login-card">
<h2>华门窗云平台</h2>
<p class="sub">画图 · 报价 · 订单 · 生产</p>
<el-form :model="form" @keyup.enter="doLogin">
<el-form-item>
<el-input v-model="form.phone_number" placeholder="手机号" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password />
</el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">
登 录
</el-button>
</el-form>
<p class="tip">开发账号:13800000000 / 123456</p>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ phone_number: '', password: '' })
async function doLogin() {
if (!form.phone_number || !form.password) return ElMessage.warning('请输入手机号和密码')
loading.value = true
try {
const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/auth/login', form)
auth.setSession(d.token, d.user?.nickname ?? '')
router.push('/dashboard')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page { height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #35506b 0%, #6b8cae 100%); }
.login-card { width: 380px; text-align: center; padding: 12px 8px; }
h2 { margin: 8px 0 4px; color: #303133; }
.sub { color: #909399; font-size: 13px; margin: 0 0 20px; }
.tip { color: #c0c4cc; font-size: 12px; margin-top: 14px; }
</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<el-row :gutter="16">
<el-col :span="12">
<el-card header="型材需求(每件长度 × 数量)">
<div v-for="(d, i) in demands" :key="i" class="row">
<el-input v-model="d.label" placeholder="用途" style="width:130px" />
<el-input-number v-model="d.length" :min="1" placeholder="长度mm" controls-position="right" />
<el-input-number v-model="d.qty" :min="1" placeholder="数量" controls-position="right" />
<el-button text type="danger" @click="demands.splice(i, 1)">删</el-button>
</div>
<el-button text type="primary" @click="demands.push({ label: '', length: 1000, qty: 1 })">+ 添加需求</el-button>
</el-card>
</el-col>
<el-col :span="12">
<el-card header="库存原料">
<div v-for="(s, i) in stocks" :key="i" class="row">
<el-input v-model="s.label" placeholder="名称" style="width:130px" />
<el-input-number v-model="s.length" :min="1" placeholder="长度mm" controls-position="right" />
<el-input-number v-model="s.qty" :min="1" placeholder="根数" controls-position="right" />
<el-button text type="danger" @click="stocks.splice(i, 1)">删</el-button>
</div>
<el-button text type="primary" @click="stocks.push({ label: '', length: 6000, qty: 1 })">+ 添加原料</el-button>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top:16px">
<div class="row">
<span>锯口损耗:</span>
<el-input-number v-model="kerf" :min="0" :max="50" controls-position="right" />
<el-button type="primary" :loading="loading" @click="run">开始优化</el-button>
</div>
<template v-if="result">
<el-descriptions :column="4" border style="margin-top:14px">
<el-descriptions-item label="总件数">{{ result.stats.total_pieces }}</el-descriptions-item>
<el-descriptions-item label="用料根数">{{ result.stats.stock_bars_used }}</el-descriptions-item>
<el-descriptions-item label="利用率">{{ (result.stats.utilization * 100).toFixed(1) }}%</el-descriptions-item>
<el-descriptions-item label="未排入">{{ result.unplaced.length }}</el-descriptions-item>
</el-descriptions>
<el-table :data="result.plans" style="margin-top:14px" border>
<el-table-column label="原料" width="200">
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
</el-table-column>
<el-table-column label="切割组合" min-width="300">
<template #default="{ row }">
<el-tag v-for="(c, i) in row.pieces" :key="i" size="small" style="margin: 2px">{{ c.label || '件' }} {{ c.length }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="waste" label="余料mm" width="100" />
</el-table>
</template>
</el-card>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
const demands = reactive([{ label: '边框', length: 1200, qty: 3 }, { label: '中梃', length: 800, qty: 2 }])
const stocks = reactive([{ label: '6米型材', length: 6000, qty: 2 }])
const kerf = ref(5)
const loading = ref(false)
const result = ref<any>(null)
async function run() {
loading.value = true
try {
result.value = await api<any>('post', '/api/optimize1d', { demands, stocks, kerf: kerf.value })
ElMessage.success(`优化完成,利用率 ${(result.value.stats.utilization * 100).toFixed(1)}%`)
} finally { loading.value = false }
}
</script>
<style scoped>
.row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
</style>
+217
View File
@@ -0,0 +1,217 @@
<template>
<div>
<el-card>
<div class="toolbar">
<el-input v-model="query.keyword" placeholder="订单号/客户/手机号" clearable style="width:240px"
@keyup.enter="load" />
<el-select v-model="query.status" placeholder="全部状态" clearable style="width:140px">
<el-option v-for="(label, val) in statusLabels" :key="val" :label="label" :value="Number(val)" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<div style="flex:1" />
<el-button type="primary" plain @click="openCreate">新建订单</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="order_no" label="订单号" width="160" />
<el-table-column prop="customer_name" label="客户" min-width="110" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="count" label="数量" width="70" />
<el-table-column prop="area" label="面积㎡" width="90" />
<el-table-column prop="money" label="金额" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="tagType(row.status)" size="small">{{ row.status_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDetail(row)">图纸</el-button>
<el-button v-for="act in nextActions(row.status)" :key="act.to" size="small"
:type="act.type" @click="changeStatus(row, act.to)">{{ act.label }}</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="query.page_size" v-model:current-page="query.page" @current-change="load" />
</el-card>
<!-- 新建订单 -->
<el-dialog v-model="createVisible" title="新建订单" width="560px">
<el-form :model="createForm" label-width="90px">
<el-form-item label="关联合同">
<el-select v-model="createForm.contract_id" placeholder="选择合同(可选)" clearable filterable style="width:100%">
<el-option v-for="c in contractOptions" :key="c.id"
:label="`${c.contract_no} ${c.customer_name}`" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="选择样板">
<el-select v-model="createForm.template_id" placeholder="从样板库带入图纸(可选)" clearable filterable
style="width:100%" @change="onTemplateChange">
<el-option v-for="t in templateOptions" :key="t.id"
:label="t.name || `样板#${t.id}`" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="数量"><el-input-number v-model="createForm.count" :min="0" /></el-form-item>
<el-form-item label="面积㎡"><el-input-number v-model="createForm.area" :min="0" :precision="2" /></el-form-item>
<el-form-item label="金额"><el-input-number v-model="createForm.money" :min="0" :precision="2" /></el-form-item>
<el-form-item label="备注"><el-input v-model="createForm.note" type="textarea" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">创建订单</el-button>
</template>
</el-dialog>
<!-- 图纸详情 -->
<el-drawer v-model="detailVisible" size="62%" :title="detail?.order_no ?? '图纸'">
<div v-if="detail" class="detail">
<div class="meta">
<el-tag>{{ detail.status_label }}</el-tag>
<span>客户:{{ detail.customer_name || '-' }}</span>
<span>金额:{{ detail.money }}</span>
<span>面积:{{ detail.area }}㎡</span>
</div>
<div class="canvas-box">
<WindoorCanvas :drawing-data="detail.drawing_data" />
</div>
<el-divider>生产进度</el-divider>
<el-steps :active="processActive" align-center>
<el-step v-for="p in detail.processes" :key="p.id" :title="p.name"
:status="p.status === 2 ? 'success' : p.status === 1 ? 'process' : 'wait'" />
</el-steps>
<div style="margin-top:12px">
<el-select v-model="processForm.name" style="width:120px">
<el-option v-for="p in detail.processes" :key="p.name" :label="p.name" :value="p.name" />
</el-select>
<el-select v-model="processForm.status" style="width:110px">
<el-option :value="0" label="未开始" /><el-option :value="1" label="进行中" /><el-option :value="2" label="完成" />
</el-select>
<el-button type="primary" @click="updateProcess">更新进度</el-button>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue'
const route = useRoute()
const statusLabels: Record<number, string> = { 0: '意向单', 1: '正式单', 2: '生产中', 3: '已完工', 4: '已取消' }
const query = reactive({ page: 1, page_size: 15, keyword: '', status: undefined as number | undefined })
const rows = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const d = await api<any>('post', '/api/orders/search', query)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
// 状态机允许的下一步动作(与后端 TRANSITIONS 对齐)
const transitions: Record<number, { to: number; label: string; type: string }[]> = {
0: [{ to: 1, label: '转正式', type: 'primary' }, { to: 4, label: '取消', type: 'warning' }],
1: [{ to: 2, label: '排产', type: 'primary' }, { to: 4, label: '取消', type: 'warning' }],
2: [{ to: 3, label: '完工', type: 'success' }],
}
function nextActions(status: number) { return transitions[status] ?? [] }
function tagType(status: number) {
return ({ 0: 'info', 1: 'primary', 2: 'warning', 3: 'success', 4: 'danger' } as const)[
status as 0 | 1 | 2 | 3 | 4] ?? 'info'
}
async function changeStatus(row: any, to: number) {
await api('put', `/api/orders/${row.id}`, { action: 'change_status', target_status: to })
ElMessage.success('状态已更新')
load()
}
async function remove(row: any) {
await ElMessageBox.confirm(`确定删除订单 ${row.order_no}?`, '提示', { type: 'warning' })
await api('delete', `/api/orders/${row.id}`)
ElMessage.success('已删除')
load()
}
// ---- 新建 ----
const createVisible = ref(false)
const saving = ref(false)
const createForm = reactive({ contract_id: undefined as number | undefined, template_id: undefined as number | undefined, count: 1, area: 0, money: 0, note: '', drawing_data: '', pic_url: '' })
const contractOptions = ref<any[]>([])
const templateOptions = ref<any[]>([])
async function openCreate() {
createVisible.value = true
const [cs, ts] = await Promise.all([
api<any>('get', '/api/contracts?page=1&page_size=100'),
api<any>('get', '/api/windoor/templates?page=1&page_size=254'),
])
contractOptions.value = cs.contracts?.data ?? []
templateOptions.value = ts
// 样板库"用此样板下单"跳转带入
const tid = Number(route.query.template)
if (tid) {
createForm.template_id = tid
await onTemplateChange(tid)
}
}
async function onTemplateChange(tid: number | undefined) {
createForm.drawing_data = ''
createForm.pic_url = ''
if (!tid) return
const t = await api<any>('get', `/api/windoor/templates/${tid}`)
createForm.drawing_data = t.drawing_data
createForm.pic_url = t.pic_url
}
async function save() {
saving.value = true
try {
const { template_id: _t, ...payload } = createForm
await api('post', '/api/orders', payload)
ElMessage.success('订单已创建')
createVisible.value = false
load()
} finally { saving.value = false }
}
// ---- 详情抽屉 ----
const detailVisible = ref(false)
const detail = ref<any>(null)
const processForm = reactive({ name: '下料', status: 1 })
const processActive = ref(0)
async function openDetail(row: any) {
detail.value = await api<any>('get', `/api/orders/${row.id}`)
detailVisible.value = true
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
}
async function updateProcess() {
await api('post', `/api/orders/${detail.value.id}/process`, processForm)
ElMessage.success('进度已更新')
detail.value = await api<any>('get', `/api/orders/${detail.value.id}`)
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.detail .meta { display: flex; gap: 16px; color: #606266; font-size: 13px; margin-bottom: 10px; align-items: center; }
.canvas-box { height: 440px; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div>
<el-card>
<div class="toolbar">
<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-select>
<el-input v-model="name" placeholder="模板名称" clearable style="width:200px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button>
<div style="flex:1" />
<span class="count">共 {{ total }} 套官方样板</span>
</div>
<div v-loading="loading" class="grid">
<div v-for="t in rows" :key="t.id" class="item" @click="preview(t)">
<el-image :src="t.pic_url" fit="contain" lazy class="pic" />
<div class="cap">{{ t.name || `样板#${t.id}` }}</div>
</div>
</div>
<el-pagination style="margin-top:12px; justify-content:center" background layout="total, prev, pager, next"
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
</el-card>
<el-dialog v-model="previewVisible" :title="current?.name || `样板#${current?.id}`" width="72%" top="4vh">
<div v-if="current" class="preview">
<div class="canvas-box">
<WindoorCanvas :drawing-data="current.drawing_data" />
</div>
<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="primary" size="small" @click="useInOrder">用此样板下单</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue'
const router = useRouter()
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const page_size = 48
const name = ref('')
const catelogId = ref<number | undefined>()
const catelogs = ref<any[]>([])
const loading = ref(false)
const previewVisible = ref(false)
const current = ref<any>(null)
async function load() {
loading.value = true
try {
const qs = `page=${page.value}&page_size=${page_size}&name=${name.value}${catelogId.value ? `&catelog_id=${catelogId.value}` : ''}`
const d = await api<any>('get', `/api/windoor/templates?${qs}`)
rows.value = d
// 接口无分页元数据时按返回量估计
total.value = d.length === page_size ? (page.value - 1) * page_size + d.length + 1 : (page.value - 1) * page_size + d.length
} finally { loading.value = false }
}
async function preview(t: any) {
current.value = await api<any>('get', `/api/windoor/templates/${t.id}`)
previewVisible.value = true
}
function useInOrder() {
router.push({ path: '/orders', query: { template: current.value.id } })
}
onMounted(async () => {
const tree = await api<any[]>('get', '/api/windoor/catelogs')
catelogs.value = tree.flatMap(t => [t, ...((t.children ?? []) as any[])])
load()
})
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; align-items: center; }
.count { color: #909399; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; min-height: 200px; }
.item { border: 1px solid #e4e7ed; border-radius: 8px; padding: 8px; cursor: pointer; background: #fff;
transition: box-shadow .2s; display: flex; flex-direction: column; align-items: center; }
.item:hover { box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.pic { width: 100%; height: 110px; }
.cap { font-size: 12px; color: #606266; margin-top: 6px; text-align: center; }
.preview .canvas-box { height: 60vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
.preview .meta { display: flex; gap: 20px; margin-top: 10px; color: #606266; font-size: 13px; align-items: center; }
</style>
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 开发时 /api 代理到后端 8000;构建产物输出 dist 由后端挂载
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
'/static': { target: 'http://localhost:8000', changeOrigin: true },
},
},
build: { outDir: 'dist', emptyOutDir: true },
})
+85
View File
@@ -0,0 +1,85 @@
"""合同路由:意向单/正式单管理。"""
from fastapi import APIRouter
from pydantic import BaseModel, Field
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.repositories.contract_repo import status_label
from huamenchuang.services.contract_service import ContractService
router = APIRouter(prefix="/contracts", tags=["合同"])
class ContractBody(BaseModel):
customer_id: int | None = None
customer_name: str = ""
phone: str = ""
address: str = ""
area: float = 0
count: int = 0
money: float = 0
note: str = ""
class ConvertBody(BaseModel):
contract_id: int
def _to_out(c) -> dict:
return {
"id": c.id, "contract_no": c.contract_no, "customer_id": c.customer_id,
"customer_name": c.customer_name, "phone": c.phone, "address": c.address,
"area": float(c.area), "count": c.count, "money": float(c.money),
"status": c.status, "status_label": status_label(c.status),
"sign_status": c.sign_status, "note": c.note,
"created_at": str(c.created_at), "updated_at": str(c.updated_at),
}
@router.post("")
async def create_contract(body: ContractBody, user: CurrentUser, db: DbSession):
"""创建合同(默认意向单)。"""
contract = await ContractService(db).create(user, body.model_dump())
return ok(data=_to_out(contract), message="创建成功")
@router.get("")
async def list_contracts(user: CurrentUser, db: DbSession, page: int = 1,
page_size: int = 15, keyword: str = "", status: int | None = None):
"""合同分页列表(结构对齐目标站 {"contracts": {...}})。"""
items, total = await ContractService(db).repo.search(
user.company_id, page, page_size, keyword, status)
return ok(data={"contracts": paginated(
[_to_out(c) for c in items], page, page_size, total)})
@router.put("/{contract_id}")
async def update_contract(contract_id: int, body: ContractBody, user: CurrentUser, db: DbSession):
"""编辑合同。"""
try:
c = await ContractService(db).update(user, contract_id, body.model_dump())
except KeyError as e:
return fail(str(e))
return ok(data=_to_out(c), message="更新成功")
@router.post("/convert")
async def convert_contract(body: ConvertBody, user: CurrentUser, db: DbSession):
"""意向单转正式单(对齐目标站 convert-contract/)。"""
try:
c = await ContractService(db).convert_to_formal(user, body.contract_id)
except KeyError as e:
return fail(str(e))
except ValueError as e:
return fail(str(e))
return ok(data=_to_out(c), message="已转为正式单")
@router.delete("/{contract_id}")
async def delete_contract(contract_id: int, user: CurrentUser, db: DbSession):
"""删除合同。"""
try:
await ContractService(db).delete(user, contract_id)
except KeyError as e:
return fail(str(e))
return ok(message="删除成功")
+68
View File
@@ -0,0 +1,68 @@
"""客户路由。"""
from fastapi import APIRouter
from pydantic import BaseModel, Field
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.services.customer_service import CustomerService
router = APIRouter(prefix="/customers", tags=["客户"])
class CustomerBody(BaseModel):
name: str = Field(min_length=1, max_length=128)
phone: str = ""
address: str = ""
note: str = ""
salesmen: str | None = None
@router.post("")
async def create_customer(body: CustomerBody, user: CurrentUser, db: DbSession):
"""新增客户。"""
customer = await CustomerService(db).create(user, body.model_dump())
return ok(data={"id": customer.id, "name": customer.name}, message="创建成功")
@router.post("/search")
async def search_customers(user: CurrentUser, db: DbSession,
page: int = 1, page_size: int = 15, keyword: str = ""):
"""客户搜索。"""
items, total = await CustomerService(db).repo.search(user.company_id, page, page_size, keyword)
return ok(data=paginated([{
"id": c.id, "name": c.name, "phone": c.phone, "address": c.address,
"note": c.note, "salesmen": c.salesmen, "balance": float(c.balance),
"created_at": str(c.created_at),
} for c in items], page, page_size, total))
@router.get("")
async def list_customers(user: CurrentUser, db: DbSession, page: int = 1,
page_size: int = 15, keyword: str = ""):
"""客户分页列表。"""
items, total = await CustomerService(db).repo.search(user.company_id, page, page_size, keyword)
return ok(data=paginated([{
"id": c.id, "name": c.name, "phone": c.phone, "address": c.address,
"note": c.note, "salesmen": c.salesmen, "balance": float(c.balance),
"created_at": str(c.created_at),
} for c in items], page, page_size, total))
@router.put("/{customer_id}")
async def update_customer(customer_id: int, body: CustomerBody, user: CurrentUser, db: DbSession):
"""编辑客户。"""
try:
c = await CustomerService(db).update(user, customer_id, body.model_dump())
except KeyError as e:
return fail(str(e))
return ok(data={"id": c.id}, message="更新成功")
@router.delete("/{customer_id}")
async def delete_customer(customer_id: int, user: CurrentUser, db: DbSession):
"""删除客户。"""
try:
await CustomerService(db).delete(user, customer_id)
except KeyError as e:
return fail(str(e))
return ok(message="删除成功")
+28
View File
@@ -0,0 +1,28 @@
"""优化下料路由:一维型材切割方案计算(无状态,纯计算)。"""
from fastapi import APIRouter
from pydantic import BaseModel, Field
from huamenchuang.core.deps import CurrentUser
from huamenchuang.core.response import fail, ok
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d
router = APIRouter(prefix="/optimize1d", tags=["优化下料"])
class OptimizeRequest(BaseModel):
demands: list[BarDemand] = Field(description="型材需求 [{length,qty,label}]")
stocks: list[StockBar] = Field(description="库存原料 [{length,qty,label}]")
kerf: int = Field(default=5, ge=0, le=50, description="锯口损耗(mm)")
@router.post("")
async def optimize(body: OptimizeRequest, user: CurrentUser):
"""一维优化下料(FFD 首递减算法)。"""
if not body.demands or not body.stocks:
return fail("需求和库存均不能为空")
total_demand = sum(d.length * d.qty for d in body.demands)
total_stock = sum(s.length * s.qty for s in body.stocks)
if total_demand > total_stock:
return fail(f"库存不足: 需求 {total_demand}mm > 库存 {total_stock}mm")
result = optimize_1d(body.demands, body.stocks, body.kerf)
return ok(data=result, message="优化完成")
+4 -1
View File
@@ -1,9 +1,12 @@
"""路由汇总。""" """路由汇总。"""
from fastapi import APIRouter from fastapi import APIRouter
from huamenchuang.api import auth, orders, windoor from huamenchuang.api import auth, contracts, customers, optimize, orders, windoor
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(orders.router) api_router.include_router(orders.router)
api_router.include_router(customers.router)
api_router.include_router(contracts.router)
api_router.include_router(windoor.router) api_router.include_router(windoor.router)
api_router.include_router(optimize.router)
+15 -2
View File
@@ -53,15 +53,28 @@ async def health():
app.include_router(api_router) app.include_router(api_router)
from fastapi.responses import FileResponse # noqa: E402
# 静态资源:样板平面图(data/windoor-images) # 静态资源:样板平面图(data/windoor-images)
_imgdir = Path(__file__).resolve().parents[2] / "data" / "windoor-images" _imgdir = Path(__file__).resolve().parents[2] / "data" / "windoor-images"
if _imgdir.exists(): if _imgdir.exists():
app.mount("/static/windoor-images", StaticFiles(directory=str(_imgdir)), name="windoor-images") app.mount("/static/windoor-images", StaticFiles(directory=str(_imgdir)), name="windoor-images")
# 前端构建产物挂载(前后端规范:构建到后端目录) # 前端构建产物挂载(前后端规范:构建到后端目录)+ history 路由回退
_dist = Path(__file__).resolve().parents[2] / "frontend" / "dist" _dist = Path(__file__).resolve().parents[2] / "frontend" / "dist"
if _dist.exists(): if _dist.exists():
app.mount("/", StaticFiles(directory=str(_dist), html=True), name="frontend") app.mount("/assets", StaticFiles(directory=str(_dist / "assets")), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str):
"""非 API/静态路径一律返回 index.html(前端 history 模式)。"""
if full_path.startswith(("api/", "static/")):
return JSONResponse(
{"code": 0, "message": "接口不存在", "data": "", "other_data": "", "other_data2": ""})
target = _dist / full_path
if full_path and target.is_file():
return FileResponse(target)
return FileResponse(_dist / "index.html")
def main() -> None: def main() -> None:
@@ -0,0 +1,55 @@
"""合同数据访问层。"""
import datetime
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from huamenchuang.models.contract import Contract
STATUS_LABELS = {0: "意向单", 1: "正式单", 2: "已完成", 3: "已作废"}
class ContractRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def next_contract_no(self, company_id: int) -> str:
"""合同号:FG+YYYYMMDD+3位流水(对齐目标站 FG20260818009 风格)。"""
today = datetime.date.today().strftime("%Y%m%d")
prefix = f"FG{today}"
stmt = select(Contract.contract_no).where(
Contract.company_id == company_id, Contract.contract_no.like(f"{prefix}%")
).order_by(Contract.contract_no.desc()).limit(1)
last = (await self.db.execute(stmt)).scalar_one_or_none()
seq = int(last[-3:]) + 1 if last else 1
return f"{prefix}{seq:03d}"
async def get(self, contract_id: int, company_id: int) -> Contract | None:
stmt = select(Contract).where(Contract.id == contract_id, Contract.company_id == company_id)
return (await self.db.execute(stmt)).scalar_one_or_none()
async def search(self, company_id: int, page: int, page_size: int, keyword: str = "",
status: int | None = None) -> tuple[list[Contract], int]:
conds = [Contract.company_id == company_id]
if status is not None:
conds.append(Contract.status == status)
if kw := keyword.strip():
like = f"%{kw}%"
conds.append(or_(Contract.contract_no.like(like), Contract.customer_name.like(like), Contract.phone.like(like)))
where = *conds,
total = (await self.db.execute(select(func.count()).select_from(Contract).where(*where))).scalar_one()
stmt = (select(Contract).where(*where).order_by(Contract.id.desc())
.offset((page - 1) * page_size).limit(page_size))
return list((await self.db.execute(stmt)).scalars()), total
async def create(self, contract: Contract) -> Contract:
self.db.add(contract)
await self.db.flush()
return contract
async def delete(self, contract: Contract) -> None:
await self.db.delete(contract)
def status_label(status: int) -> str:
return STATUS_LABELS.get(status, "未知")
@@ -0,0 +1,33 @@
"""客户数据访问层。"""
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from huamenchuang.models.contract import Customer
class CustomerRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get(self, customer_id: int, company_id: int) -> Customer | None:
stmt = select(Customer).where(Customer.id == customer_id, Customer.company_id == company_id)
return (await self.db.execute(stmt)).scalar_one_or_none()
async def search(self, company_id: int, page: int, page_size: int, keyword: str = "") -> tuple[list[Customer], int]:
conds = [Customer.company_id == company_id]
if kw := keyword.strip():
like = f"%{kw}%"
conds.append(or_(Customer.name.like(like), Customer.phone.like(like), Customer.address.like(like)))
where = *conds,
total = (await self.db.execute(select(func.count()).select_from(Customer).where(*where))).scalar_one()
stmt = (select(Customer).where(*where).order_by(Customer.id.desc())
.offset((page - 1) * page_size).limit(page_size))
return list((await self.db.execute(stmt)).scalars()), total
async def create(self, customer: Customer) -> Customer:
self.db.add(customer)
await self.db.flush()
return customer
async def delete(self, customer: Customer) -> None:
await self.db.delete(customer)
@@ -0,0 +1,44 @@
"""合同业务逻辑层:意向单→正式单流转。"""
from sqlalchemy.ext.asyncio import AsyncSession
from huamenchuang.models.contract import Contract
from huamenchuang.models.user import User
from huamenchuang.repositories.contract_repo import ContractRepository
class ContractService:
def __init__(self, db: AsyncSession) -> None:
self.repo = ContractRepository(db)
async def create(self, user: User, data: dict) -> Contract:
contract = await self.repo.create(Contract(
company_id=user.company_id,
user_id=user.id,
contract_no=await self.repo.next_contract_no(user.company_id),
**data,
))
return contract
async def update(self, user: User, contract_id: int, data: dict) -> Contract:
contract = await self.repo.get(contract_id, user.company_id)
if contract is None:
raise KeyError("合同不存在")
for k, v in data.items():
setattr(contract, k, v)
return contract
async def convert_to_formal(self, user: User, contract_id: int) -> Contract:
"""意向单转正式单(对齐目标站 convert-contract 语义)。"""
contract = await self.repo.get(contract_id, user.company_id)
if contract is None:
raise KeyError("合同不存在")
if contract.status != 0:
raise ValueError("仅意向单可转正式单")
contract.status = 1
return contract
async def delete(self, user: User, contract_id: int) -> None:
contract = await self.repo.get(contract_id, user.company_id)
if contract is None:
raise KeyError("合同不存在")
await self.repo.delete(contract)
@@ -0,0 +1,28 @@
"""客户业务逻辑层。"""
from sqlalchemy.ext.asyncio import AsyncSession
from huamenchuang.models.contract import Customer
from huamenchuang.models.user import User
from huamenchuang.repositories.customer_repo import CustomerRepository
class CustomerService:
def __init__(self, db: AsyncSession) -> None:
self.repo = CustomerRepository(db)
async def create(self, user: User, data: dict) -> Customer:
return await self.repo.create(Customer(company_id=user.company_id, **data))
async def update(self, user: User, customer_id: int, data: dict) -> Customer:
customer = await self.repo.get(customer_id, user.company_id)
if customer is None:
raise KeyError("客户不存在")
for k, v in data.items():
setattr(customer, k, v)
return customer
async def delete(self, user: User, customer_id: int) -> None:
customer = await self.repo.get(customer_id, user.company_id)
if customer is None:
raise KeyError("客户不存在")
await self.repo.delete(customer)
@@ -0,0 +1,104 @@
"""一维优化下料服务:首递减(FFD)装箱算法,输出切割方案与利用率。
对齐目标站 optimize1d 语义:输入订单型材需求 + 库存原料,输出每根原料的切割组合。
"""
from dataclasses import dataclass, field
@dataclass
class BarDemand:
"""一段型材需求。"""
length: int # 需求长度(mm)
qty: int # 数量
label: str = "" # 用途标签
@dataclass
class StockBar:
"""库存原料。"""
length: int # 原料长度(mm)
qty: int # 库存根数
label: str = ""
@dataclass
class CutPiece:
length: int
label: str = ""
@dataclass
class CutPlan:
"""一根原料的切割方案。"""
stock_length: int
stock_label: str
pieces: list[CutPiece] = field(default_factory=list)
waste: int = 0 # 余料浪费
# 锯口损耗(mm),每次切割损耗一段
KERF_DEFAULT = 5
def optimize_1d(demands: list[BarDemand], stocks: list[StockBar],
kerf: int = KERF_DEFAULT) -> dict:
"""首递减装箱:长件优先装入可容纳的原料,输出方案与统计。"""
# 展开需求并降序
pieces: list[CutPiece] = []
for d in demands:
pieces.extend(CutPiece(length=d.length, label=d.label) for _ in range(max(0, d.qty)))
pieces.sort(key=lambda p: p.length, reverse=True)
# 展开库存(可切割长度 = 原料长)
avail: list[dict] = []
for s in stocks:
for _ in range(max(0, s.qty)):
avail.append({"length": s.length, "label": s.label, "pieces": [], "used": 0})
unplaced: list[CutPiece] = []
for piece in pieces:
# 找第一根装得下的原料(剩余空间 >= 件长 + 锯口)
placed = False
for bar in avail:
remain = bar["length"] - bar["used"]
cost = piece.length + (kerf if bar["pieces"] else 0)
if remain >= cost:
bar["pieces"].append(piece)
bar["used"] += cost
placed = True
break
if not placed:
unplaced.append(piece)
plans = [
CutPlan(
stock_length=b["length"],
stock_label=b["label"],
pieces=b["pieces"],
waste=b["length"] - b["used"],
)
for b in avail if b["pieces"]
]
total_demand = sum(p.length for p in pieces)
total_stock = sum(p.stock_length for p in plans)
return {
"plans": [
{
"stock_length": p.stock_length,
"stock_label": p.stock_label,
"pieces": [{"length": c.length, "label": c.label} for c in p.pieces],
"waste": p.waste,
}
for p in plans
],
"unplaced": [{"length": c.length, "label": c.label} for c in unplaced],
"stats": {
"total_pieces": len(pieces),
"placed_pieces": len(pieces) - len(unplaced),
"stock_bars_used": len(plans),
"total_demand_mm": total_demand,
"total_stock_mm": total_stock,
"utilization": round(total_demand / total_stock, 4) if total_stock else 0,
"kerf_mm": kerf,
},
}
+15 -2
View File
@@ -14,13 +14,26 @@ class OrderService:
self.repo = OrderRepository(db) self.repo = OrderRepository(db)
async def create_order(self, user: User, payload: OrderCreate) -> Order: async def create_order(self, user: User, payload: OrderCreate) -> Order:
"""创建订单:对齐目标站 {contract_id, drawing_data, pic_url} 契约。""" """创建订单:对齐目标站 {contract_id, drawing_data, pic_url} 契约。
指定 contract_id 时自动快照合同客户信息。"""
customer_id = payload.customer_id
customer_name = phone = address = ""
if payload.contract_id:
from huamenchuang.repositories.contract_repo import ContractRepository
contract = await ContractRepository(self.db).get(payload.contract_id, user.company_id)
if contract is None:
raise KeyError("关联合同不存在")
customer_id = contract.customer_id
customer_name, phone, address = contract.customer_name, contract.phone, contract.address
order = Order( order = Order(
order_no=await self.repo.next_order_no(user.company_id), order_no=await self.repo.next_order_no(user.company_id),
contract_id=payload.contract_id, contract_id=payload.contract_id,
drawing_data=payload.drawing_data, drawing_data=payload.drawing_data,
pic_url=payload.pic_url, pic_url=payload.pic_url,
customer_id=payload.customer_id, customer_id=customer_id,
customer_name=customer_name,
phone=phone,
address=address,
note=payload.note, note=payload.note,
money=payload.money, money=payload.money,
count=payload.count, count=payload.count,