diff --git a/CHANGELOG b/CHANGELOG index 8a80179..aedb710 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # CHANGELOG +- [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 10:39] FEAT: v0.5 画布尺寸编辑+自动报价+扇样式保真修复——①参数化尺寸编辑: W/H输入框与拖拽外框右缘/下缘/右下角手柄, 全部几何按比例自动缩放(料宽不变,最小300mm), 拖拽静默/松手提交, 撤销栈; ②自动报价: 画布变化600ms防抖自动调offer-price并实时显示总价(样板库/订单详情); ③修复"样板显示为默认样式": 扇样式推断引擎三级解析(有效bd→polyId区域映射双扇对半→帧bbox按扇数竖向均分), 还原真实分格/开向(od角度/inward空心标记/门标注), 无图纸订单显示引导提示 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/views/*, doc/API.md) - [2026-09-20 09:04] FEAT: v0.4 画布编辑器(拖拽布置v1)——WindoorCanvas 增加 editable 模式:+竖梃/+横梃点击玻璃区域实时分割(两次交叉点法多边形重算+mm.sp分割线同步)、撤销栈;订单详情可保存图纸(PUT orders),样板库可另存我的样板(新增 POST windoor/templates is_default=0);10接口回归全通过 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/views/*, src/huamenchuang/api/windoor.py, src/huamenchuang/schemas/windoor.py, doc/API.md) diff --git a/doc/API.md b/doc/API.md index ca0eac5..6cbfb67 100644 --- a/doc/API.md +++ b/doc/API.md @@ -294,7 +294,45 @@ **自动报价**(v0.5):画布变化(改尺寸/加分格)后 600ms 防抖自动调用 offer-price,页面实时显示当前尺寸总价。 **报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。 -### 12.11 种子数据 +### 12.11 业务扩展模块(v0.7,`/api`) + +| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | +|---|---|---|---|---| +| 产品列表 | GET | `/api/products?page=&keyword=&material=` | query | 产品价格本(型材/玻璃/五金/配件) | +| 新增/编辑/删除产品 | POST/PUT/DELETE | `/api/products[/{id}]` | `{name, material, spec, unit, price, cost}` | | +| 保存报价单 | POST | `/api/quotation` | `{customer_id, items[], total, drawing_data, ...}` | 单号 `BJ+YYYYMMDD+3位流水` | +| 报价单列表/详情/更新/删除 | GET/PUT/DELETE | `/api/quotations[/{id}]` | 分页+keyword+status | status: 0草稿 1已发送 2已成交 3作废 | +| 提交售后单 | POST | `/api/after_sales` | `{title, description(必填), order_no?}` | 对齐原站"问题描述必填"校验 | +| 售后列表/状态流转 | GET/PUT | `/api/after_sales[/{id}]` | status: 0待处理 1处理中 2已完成 | | +| 创建量尺单 | POST | `/api/measures` | `{customer_name, phone, address, measure_date, surveyor, data[{name,width,height,qty}]}` | 对齐原站量尺管理 | +| 量尺列表/更新 | GET/PUT | `/api/measures[/{id}]` | status: 0待量尺 1已量尺 2已出图 | | +| 阳光房订单 | GET | `/api/sunny_house_orders` | 分页 | 订单按 `order_type=1` 过滤(Order 新增 order_type 字段) | +| 修改资料/密码 | PUT | `/api/auth/profile` `/api/auth/password` | — | 对齐原站个人中心 | +| 邀请信息 | GET | `/api/auth/invitation` | — | 邀请码 `HMC+6位用户ID` | + +### 12.12 前端页面总览(v0.7,15 个页面,响应式布局) + +| 路由 | 页面 | 说明 | +|---|---|---| +| `/login` | 登录/注册 | 双 Tab(注册对齐原站 register) | +| `/dashboard` | 工作台 | 统计 + 快捷入口 | +| `/orders` | 订单管理 | 搜索/创建/图纸编辑/状态机/进度/报价/打印 | +| `/sunny` | 阳光房 | 阳光房订单(order_type=1) | +| `/quotations` | 报价单 | 手动报价(选样板自动算料+手动调整)/转订单 | +| `/contracts` | 合同管理 | 意向单→正式单/一键下单 | +| `/customers` | 客户管理 | CRUD | +| `/measure` | 量尺管理 | 量尺单 + 洞口尺寸清单 | +| `/aftersale` | 售后管理 | 提交/流转 | +| `/templates` | 样板库 | 254 套样板/画布预览/编辑/报价/下单 | +| `/products` | 产品管理 | 价格本 CRUD | +| `/warehouse` | 仓库库存 | 出入库/预警 | +| `/optimize` | 优化下料 | FFD 计算 | +| `/market` | 商城 | 模块开通(购买流程占位,支付需外部资质) | +| `/guide` + `/personal` | 使用指南/个人中心 | 帮助文档/资料/密码/邀请返利 | + +**响应式适配**:侧边栏 <900px 自动折叠为图标栏;<700px 主区边距收窄;表格列横向滚动。 + +### 12.13 种子数据 ```bash uv sync # 安装依赖 diff --git a/frontend/src/main.ts b/frontend/src/main.ts index d0271bf..63b2a39 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,9 +1,14 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' 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') +const app = createApp(App) +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} +app.use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 35e8921..abb8670 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -13,11 +13,19 @@ const router = createRouter({ { path: '', redirect: '/dashboard' }, { path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } }, { path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } }, + { path: 'sunny', component: () => import('../views/SunnyHouseView.vue'), meta: { title: '阳光房' } }, + { path: 'quotations', component: () => import('../views/QuotationsView.vue'), meta: { title: '报价单' } }, { path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } }, { path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } }, + { path: 'measure', component: () => import('../views/MeasureView.vue'), meta: { title: '量尺管理' } }, + { path: 'aftersale', component: () => import('../views/AfterSaleView.vue'), meta: { title: '售后管理' } }, { path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } }, + { path: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } }, { path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } }, { path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } }, + { path: 'market', component: () => import('../views/MarketView.vue'), meta: { title: '商城' } }, + { path: 'guide', component: () => import('../views/GuideView.vue'), meta: { title: '使用指南' } }, + { path: 'personal', component: () => import('../views/PersonalView.vue'), meta: { title: '个人中心' } }, ], }, ], diff --git a/frontend/src/views/AfterSaleView.vue b/frontend/src/views/AfterSaleView.vue new file mode 100644 index 0000000..faab292 --- /dev/null +++ b/frontend/src/views/AfterSaleView.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/views/GuideView.vue b/frontend/src/views/GuideView.vue new file mode 100644 index 0000000..881db10 --- /dev/null +++ b/frontend/src/views/GuideView.vue @@ -0,0 +1,30 @@ + diff --git a/frontend/src/views/LayoutView.vue b/frontend/src/views/LayoutView.vue index 6cead86..b67679b 100644 --- a/frontend/src/views/LayoutView.vue +++ b/frontend/src/views/LayoutView.vue @@ -1,25 +1,36 @@ diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index c1164d3..6e12a2e 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -3,18 +3,34 @@

华门窗云平台

画图 · 报价 · 订单 · 生产

- - - - - - - - - 登 录 - - -

开发账号:13800000000 / 123456

+ + + + + + + + + + 登 录 + + + + + + + + + + + + + + 注 册 + + + +

开发账号:13800000000 / 123456 | 忘记密码请联系管理员

@@ -29,19 +45,36 @@ import { useAuthStore } from '../stores/auth' const router = useRouter() const auth = useAuthStore() const loading = ref(false) -const form = reactive({ phone_number: '', password: '' }) +const tab = ref('login') +const loginForm = reactive({ phone_number: '', password: '' }) +const regForm = reactive({ phone_number: '', password: '', invite_code: '' }) async function doLogin() { - if (!form.phone_number || !form.password) return ElMessage.warning('请输入手机号和密码') + if (!loginForm.phone_number || !loginForm.password) return ElMessage.warning('请输入手机号和密码') loading.value = true try { - const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/auth/login', form) + const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/auth/login', loginForm) auth.setSession(d.token, d.user?.nickname ?? '') router.push('/dashboard') } finally { loading.value = false } } + +async function doRegister() { + if (!regForm.phone_number || !regForm.password) return ElMessage.warning('请输入手机号和密码') + loading.value = true + try { + const d = await api<{ token: string; user_id: number }>('post', '/api/auth/register', { + phone_number: regForm.phone_number, password: regForm.password, + }) + auth.setSession(d.token, regForm.phone_number.slice(-4)) + ElMessage.success('注册成功') + router.push('/dashboard') + } finally { + loading.value = false + } +} diff --git a/frontend/src/views/MarketView.vue b/frontend/src/views/MarketView.vue new file mode 100644 index 0000000..ce5b138 --- /dev/null +++ b/frontend/src/views/MarketView.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/frontend/src/views/MeasureView.vue b/frontend/src/views/MeasureView.vue new file mode 100644 index 0000000..6964c0a --- /dev/null +++ b/frontend/src/views/MeasureView.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/frontend/src/views/PersonalView.vue b/frontend/src/views/PersonalView.vue new file mode 100644 index 0000000..de68e6a --- /dev/null +++ b/frontend/src/views/PersonalView.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend/src/views/ProductsView.vue b/frontend/src/views/ProductsView.vue new file mode 100644 index 0000000..6884550 --- /dev/null +++ b/frontend/src/views/ProductsView.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/views/QuotationsView.vue b/frontend/src/views/QuotationsView.vue new file mode 100644 index 0000000..f35fef2 --- /dev/null +++ b/frontend/src/views/QuotationsView.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/views/SunnyHouseView.vue b/frontend/src/views/SunnyHouseView.vue new file mode 100644 index 0000000..3f28e9c --- /dev/null +++ b/frontend/src/views/SunnyHouseView.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/src/huamenchuang/api/auth.py b/src/huamenchuang/api/auth.py index bd86a2c..40ac55c 100644 --- a/src/huamenchuang/api/auth.py +++ b/src/huamenchuang/api/auth.py @@ -1,5 +1,6 @@ -"""认证路由:登录/注册/当前用户。""" +"""认证路由:登录/注册/当前用户/个人中心。""" from fastapi import APIRouter +from pydantic import BaseModel from huamenchuang.core.deps import CurrentUser, DbSession from huamenchuang.core.response import fail, ok @@ -42,8 +43,54 @@ async def register(body: LoginRequest, db: DbSession): @router.get("/current-user") async def current_user(user: CurrentUser): """当前登录用户信息。""" + from huamenchuang.core.config import settings return ok(data={ "id": user.id, "nickname": user.nickname, "phone_number": user.phone_number, "company_id": user.company_id, "language": user.language, + "enabled_modules": settings.enabled_modules, "try_expiration_time": str(user.try_expiration_time) if user.try_expiration_time else None, }) + + +class ProfileBody(BaseModel): + nickname: str | None = None + email: str | None = None + + +class PasswordBody(BaseModel): + old_password: str + new_password: str + + +@router.put("/profile") +async def update_profile(body: ProfileBody, user: CurrentUser, db: DbSession): + """修改个人资料(对齐原站 personal-center)。""" + from huamenchuang.services.auth_service import hash_password # noqa: F401 + if body.nickname is not None: + user.nickname = body.nickname[:64] + if body.email is not None: + user.email = body.email[:128] + return ok(message="资料已更新") + + +@router.put("/password") +async def change_password(body: PasswordBody, user: CurrentUser, db: DbSession): + """修改密码(校验旧密码)。""" + from huamenchuang.services.auth_service import hash_password, verify_password + if not verify_password(body.old_password, user.password_hash): + return fail("旧密码错误") + if len(body.new_password) < 4: + return fail("新密码至少4位") + user.password_hash = hash_password(body.new_password) + return ok(message="密码已修改") + + +@router.get("/invitation") +async def invitation_info(user: CurrentUser): + """邀请信息(对齐原站 personal-center/invitation 返利)。""" + return ok(data={ + "invite_code": f"HMC{user.id:06d}", + "invited_count": 0, + "bounty": "0.00", + "rule": "好友通过邀请码注册并购买后,可获得相应奖励", + }) diff --git a/src/huamenchuang/api/business.py b/src/huamenchuang/api/business.py new file mode 100644 index 0000000..a21a01f --- /dev/null +++ b/src/huamenchuang/api/business.py @@ -0,0 +1,331 @@ +"""业务扩展路由:产品价格本、报价单、售后、量尺、阳光房订单过滤。""" +import datetime + +from fastapi import APIRouter +from pydantic import BaseModel, Field +from sqlalchemy import func, or_, select + +from huamenchuang.core.database import SessionLocal +from huamenchuang.core.deps import CurrentUser, DbSession +from huamenchuang.core.response import fail, ok, paginated +from huamenchuang.models.business import AfterSale, MeasureOrder, Product, Quotation +from huamenchuang.models.order import Order + +router = APIRouter(tags=["业务扩展"]) + + +# ---------------- 产品价格本 ---------------- + +class ProductBody(BaseModel): + name: str = Field(min_length=1, max_length=128) + material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon)$") + spec: str = "" + unit: str = "米" + price: float = 0 + cost: float = 0 + note: str = "" + enabled: int = 1 + + +@router.get("/products") +async def list_products(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15, + keyword: str = "", material: str | None = None): + conds = [Product.company_id == user.company_id] + if material: + conds.append(Product.material == material) + if kw := keyword.strip(): + like = f"%{kw}%" + conds.append(or_(Product.name.like(like), Product.spec.like(like))) + where = *conds, + total = (await db.execute(select(func.count()).select_from(Product).where(*where))).scalar_one() + stmt = (select(Product).where(*where).order_by(Product.id.desc()) + .offset((page - 1) * page_size).limit(page_size)) + items = list((await db.execute(stmt)).scalars()) + return ok(data=paginated([{ + "id": p.id, "name": p.name, "material": p.material, "spec": p.spec, "unit": p.unit, + "price": float(p.price), "cost": float(p.cost), "enabled": p.enabled, + "note": p.note, + } for p in items], page, page_size, total)) + + +@router.post("/products") +async def create_product(body: ProductBody, user: CurrentUser, db: DbSession): + p = Product(company_id=user.company_id, **body.model_dump()) + db.add(p) + await db.flush() + return ok(data={"id": p.id}, message="创建成功") + + +@router.put("/products/{pid}") +async def update_product(pid: int, body: ProductBody, user: CurrentUser, db: DbSession): + p = await db.get(Product, pid) + if p is None or p.company_id != user.company_id: + return fail("产品不存在") + for k, v in body.model_dump().items(): + setattr(p, k, v) + return ok(message="更新成功") + + +@router.delete("/products/{pid}") +async def delete_product(pid: int, user: CurrentUser, db: DbSession): + p = await db.get(Product, pid) + if p is None or p.company_id != user.company_id: + return fail("产品不存在") + await db.delete(p) + return ok(message="删除成功") + + +# ---------------- 报价单 ---------------- + +class QuotationBody(BaseModel): + customer_id: int | None = None + customer_name: str = "" + contract_id: int | None = None + template_id: int | None = None + drawing_data: str = "" + items: list[dict] = [] + glass_area: float = 0 + width_mm: int = 0 + height_mm: int = 0 + total: float = 0 + note: str = "" + status: int = Field(default=0, ge=0, le=3) + + +async def _next_quote_no(db, company_id: int) -> str: + today = datetime.date.today().strftime("%Y%m%d") + prefix = f"BJ{today}" + stmt = select(Quotation.quote_no).where( + Quotation.company_id == company_id, Quotation.quote_no.like(f"{prefix}%") + ).order_by(Quotation.quote_no.desc()).limit(1) + import json as _json + last = (await db.execute(stmt)).scalar_one_or_none() + seq = int(last[-3:]) + 1 if last else 1 + return f"{prefix}{seq:03d}" + + +def _quote_out(q: Quotation) -> dict: + return { + "id": q.id, "quote_no": q.quote_no, "customer_id": q.customer_id, + "customer_name": q.customer_name, "contract_id": q.contract_id, + "template_id": q.template_id, "glass_area": float(q.glass_area), + "width_mm": q.width_mm, "height_mm": q.height_mm, "total": float(q.total), + "status": q.status, "note": q.note, "created_at": str(q.created_at), + } + + +@router.post("/quotations") +async def create_quotation(body: QuotationBody, user: CurrentUser, db: DbSession): + q = Quotation( + company_id=user.company_id, user_id=user.id, + quote_no=await _next_quote_no(db, user.company_id), + items=__import__("json").dumps(body.items, ensure_ascii=False), + **{k: v for k, v in body.model_dump().items() if k != "items"}, + ) + db.add(q) + await db.flush() + return ok(data=_quote_out(q), message="报价单已保存") + + +@router.get("/quotations") +async def list_quotations(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15, + keyword: str = "", status: int | None = None): + conds = [Quotation.company_id == user.company_id] + if status is not None: + conds.append(Quotation.status == status) + if kw := keyword.strip(): + like = f"%{kw}%" + conds.append(or_(Quotation.quote_no.like(like), Quotation.customer_name.like(like))) + where = *conds, + total = (await db.execute(select(func.count()).select_from(Quotation).where(*where))).scalar_one() + stmt = (select(Quotation).where(*where).order_by(Quotation.id.desc()) + .offset((page - 1) * page_size).limit(page_size)) + items = list((await db.execute(stmt)).scalars()) + return ok(data=paginated([_quote_out(q) for q in items], page, page_size, total)) + + +@router.get("/quotations/{qid}") +async def quotation_detail(qid: int, user: CurrentUser, db: DbSession): + q = await db.get(Quotation, qid) + if q is None or q.company_id != user.company_id: + return fail("报价单不存在") + import json as _json + d = _quote_out(q) + d["items"] = _json.loads(q.items or "[]") + d["drawing_data"] = q.drawing_data + return ok(data=d) + + +@router.put("/quotations/{qid}") +async def update_quotation(qid: int, body: QuotationBody, user: CurrentUser, db: DbSession): + q = await db.get(Quotation, qid) + if q is None or q.company_id != user.company_id: + return fail("报价单不存在") + for k, v in body.model_dump().items(): + if k == "items": + q.items = __import__("json").dumps(v, ensure_ascii=False) + else: + setattr(q, k, v) + return ok(data=_quote_out(q), message="更新成功") + + +@router.delete("/quotations/{qid}") +async def delete_quotation(qid: int, user: CurrentUser, db: DbSession): + q = await db.get(Quotation, qid) + if q is None or q.company_id != user.company_id: + return fail("报价单不存在") + await db.delete(q) + return ok(message="删除成功") + + +# ---------------- 售后单 ---------------- + +class AfterSaleBody(BaseModel): + title: str = "" + description: str = Field(min_length=1, description="问题描述(必填,对齐原站校验)") + order_id: int | None = None + order_no: str = "" + status: int = Field(default=0, ge=0, le=2) + result: str = "" + + +@router.post("/after_sales") +async def create_after_sale(body: AfterSaleBody, user: CurrentUser, db: DbSession): + a = AfterSale(company_id=user.company_id, user_id=user.id, **body.model_dump()) + db.add(a) + await db.flush() + return ok(data={"id": a.id}, message="售后单已提交") + + +@router.get("/after_sales") +async def list_after_sales(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15, + status: int | None = None): + conds = [AfterSale.company_id == user.company_id] + if status is not None: + conds.append(AfterSale.status == status) + where = *conds, + total = (await db.execute(select(func.count()).select_from(AfterSale).where(*where))).scalar_one() + stmt = (select(AfterSale).where(*where).order_by(AfterSale.id.desc()) + .offset((page - 1) * page_size).limit(page_size)) + items = list((await db.execute(stmt)).scalars()) + return ok(data=paginated([{ + "id": a.id, "title": a.title, "description": a.description, "order_id": a.order_id, + "order_no": a.order_no, "status": a.status, "result": a.result, + "created_at": str(a.created_at), + } for a in items], page, page_size, total)) + + +class AfterSaleFlowBody(BaseModel): + status: int = Field(ge=0, le=2) + result: str = "" + + +@router.put("/after_sales/{aid}") +async def update_after_sale(aid: int, body: AfterSaleFlowBody, user: CurrentUser, db: DbSession): + """售后状态流转。""" + a = await db.get(AfterSale, aid) + if a is None or a.company_id != user.company_id: + return fail("售后单不存在") + a.status = body.status + if body.result: + a.result = body.result + return ok(message="状态已更新") + + +class MeasureUpdateBody(BaseModel): + customer_name: str | None = None + phone: str | None = None + address: str | None = None + measure_date: str | None = None + surveyor: str | None = None + status: int | None = Field(default=None, ge=0, le=2) + data: list[dict] | None = None + note: str | None = None + + +@router.put("/measures/{mid}") +async def update_measure(mid: int, body: MeasureUpdateBody, user: CurrentUser, db: DbSession): + """更新量尺单。""" + m = await db.get(MeasureOrder, mid) + if m is None or m.company_id != user.company_id: + return fail("量尺单不存在") + data = body.model_dump(exclude_none=True) + md = data.pop("measure_date", None) + if md is not None: + try: + m.measure_date = datetime.datetime.fromisoformat(md) + except ValueError: + return fail("日期格式错误") + items = data.pop("data", None) + for k, v in data.items(): + setattr(m, k, v) + if items is not None: + m.data = __import__("json").dumps(items, ensure_ascii=False) + return ok(message="更新成功") + + +# ---------------- 量尺单 ---------------- + +class MeasureBody(BaseModel): + customer_id: int | None = None + customer_name: str = "" + phone: str = "" + address: str = "" + measure_date: str | None = None + surveyor: str = "" + status: int = Field(default=0, ge=0, le=2) + data: list[dict] = [] # 洞口尺寸 [{name,width,height,qty}] + note: str = "" + + +@router.post("/measures") +async def create_measure(body: MeasureBody, user: CurrentUser, db: DbSession): + md = None + if body.measure_date: + try: + md = datetime.datetime.fromisoformat(body.measure_date) + except ValueError: + return fail("量尺日期格式错误") + m = MeasureOrder(company_id=user.company_id, customer_id=body.customer_id, + customer_name=body.customer_name, phone=body.phone, address=body.address, + measure_date=md, surveyor=body.surveyor, status=body.status, + data=__import__("json").dumps(body.data, ensure_ascii=False), note=body.note) + db.add(m) + await db.flush() + return ok(data={"id": m.id}, message="量尺单已创建") + + +@router.get("/measures") +async def list_measures(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15, + status: int | None = None): + conds = [MeasureOrder.company_id == user.company_id] + if status is not None: + conds.append(MeasureOrder.status == status) + where = *conds, + total = (await db.execute(select(func.count()).select_from(MeasureOrder).where(*where))).scalar_one() + stmt = (select(MeasureOrder).where(*where).order_by(MeasureOrder.id.desc()) + .offset((page - 1) * page_size).limit(page_size)) + items = list((await db.execute(stmt)).scalars()) + import json as _json + return ok(data=paginated([{ + "id": m.id, "customer_name": m.customer_name, "phone": m.phone, "address": m.address, + "measure_date": str(m.measure_date) if m.measure_date else None, "surveyor": m.surveyor, + "status": m.status, "data": _json.loads(m.data or "[]"), "note": m.note, + "created_at": str(m.created_at), + } for m in items], page, page_size, total)) + + +# ---------------- 阳光房订单(订单按类型过滤) ---------------- + +@router.get("/sunny_house_orders") +async def list_sunny_orders(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15): + """阳光房订单列表(order_type=1,对齐目标站 sunny_house_order/)。""" + conds = [Order.company_id == user.company_id, Order.order_type == 1] + total = (await db.execute(select(func.count()).select_from(Order).where(*conds))).scalar_one() + stmt = (select(Order).where(*conds).order_by(Order.id.desc()) + .offset((page - 1) * page_size).limit(page_size)) + items = list((await db.execute(stmt)).scalars()) + return ok(data=paginated([{ + "id": o.id, "order_no": o.order_no, "customer_name": o.customer_name, + "money": float(o.money), "status": o.status, "created_at": str(o.created_at), + } for o in items], page, page_size, total)) diff --git a/src/huamenchuang/api/router.py b/src/huamenchuang/api/router.py index 3c61386..5452c4a 100644 --- a/src/huamenchuang/api/router.py +++ b/src/huamenchuang/api/router.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from huamenchuang.api import ( auth, + business, contracts, customers, optimize, @@ -21,3 +22,4 @@ api_router.include_router(windoor.router) api_router.include_router(optimize.router) api_router.include_router(quote.router) api_router.include_router(warehouse.router) +api_router.include_router(business.router) diff --git a/src/huamenchuang/models/__init__.py b/src/huamenchuang/models/__init__.py index 7c08e01..b442367 100644 --- a/src/huamenchuang/models/__init__.py +++ b/src/huamenchuang/models/__init__.py @@ -1,4 +1,11 @@ """ORM 模型统一导出。""" +from huamenchuang.models.business import ( + AfterSale, + MeasureOrder, + Product, + ProductCatelog, + Quotation, +) from huamenchuang.models.contract import ( Contract, Customer, @@ -11,12 +18,17 @@ from huamenchuang.models.user import User from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate __all__ = [ + "AfterSale", "Contract", "Customer", "DEFAULT_PROCESSES", + "MeasureOrder", "Order", "OrderLog", "OrderProcess", + "Product", + "ProductCatelog", + "Quotation", "ReceiptAccount", "StockMove", "User", diff --git a/src/huamenchuang/models/business.py b/src/huamenchuang/models/business.py new file mode 100644 index 0000000..20868fd --- /dev/null +++ b/src/huamenchuang/models/business.py @@ -0,0 +1,113 @@ +"""产品、报价单、售后单、量尺单模型(业务扩展模块)。""" +import datetime + +from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from huamenchuang.core.database import Base + + +class Product(Base): + """产品价格本(型材/玻璃/五金/配件),供报价算料取价。""" + + __tablename__ = "products" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + company_id: Mapped[int] = mapped_column(default=1, index=True) + catelog_id: Mapped[int | None] = mapped_column(ForeignKey("product_catelogs.id"), nullable=True) + name: Mapped[str] = mapped_column(String(128)) + # profile型材/glass玻璃/hardware五金/addon配件 + material: Mapped[str] = mapped_column(String(16), default="profile", index=True) + spec: Mapped[str] = mapped_column(String(64), default="") + unit: Mapped[str] = mapped_column(String(8), default="米") # 计价单位 + price: Mapped[float] = mapped_column(Numeric(12, 2), default=0) + cost: Mapped[float] = mapped_column(Numeric(12, 2), default=0) + note: Mapped[str] = mapped_column(Text, default="") + enabled: Mapped[int] = mapped_column(default=1) + created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + + +class ProductCatelog(Base): + """产品分类树。""" + + __tablename__ = "product_catelogs" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(64)) + fid: Mapped[int] = mapped_column(default=0, index=True) + company_id: Mapped[int] = mapped_column(default=1, index=True) + + +class Quotation(Base): + """报价单:画布算料结果 + 手动调整,归属客户/合同。""" + + __tablename__ = "quotations" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + quote_no: Mapped[str] = mapped_column(String(32), unique=True) # BJ+日期+流水 + company_id: Mapped[int] = mapped_column(default=1, index=True) + user_id: Mapped[int] = mapped_column(default=0) + customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id"), nullable=True) + customer_name: Mapped[str] = mapped_column(String(128), default="") + contract_id: Mapped[int | None] = mapped_column(ForeignKey("contracts.id"), nullable=True) + template_id: Mapped[int | None] = mapped_column(default=None) + drawing_data: Mapped[str] = mapped_column(Text, default="") + # 报价明细 JSON: [{kind,name,length,qty,unit_price,unit,amount}] + items: Mapped[str] = mapped_column(Text, default="[]") + glass_area: Mapped[float] = mapped_column(Numeric(10, 3), default=0) + width_mm: Mapped[int] = mapped_column(default=0) + height_mm: Mapped[int] = mapped_column(default=0) + total: Mapped[float] = mapped_column(Numeric(12, 2), default=0) + note: Mapped[str] = mapped_column(Text, default="") + status: Mapped[int] = mapped_column(default=0) # 0草稿 1已发送 2已成交 3已作废 + created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + + +class AfterSale(Base): + """售后单(对齐目标站 after_sales_orders:问题描述必填)。""" + + __tablename__ = "after_sales" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + company_id: Mapped[int] = mapped_column(default=1, index=True) + user_id: Mapped[int] = mapped_column(default=0) + order_id: Mapped[int | None] = mapped_column(default=None, index=True) + order_no: Mapped[str] = mapped_column(String(32), default="") + title: Mapped[str] = mapped_column(String(128), default="") + description: Mapped[str] = mapped_column(Text, default="") # 必填(对齐原站校验) + status: Mapped[int] = mapped_column(default=0) # 0待处理 1处理中 2已完成 + result: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + + +class MeasureOrder(Base): + """量尺单(对齐目标站 measure-management)。""" + + __tablename__ = "measure_orders" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + company_id: Mapped[int] = mapped_column(default=1, index=True) + customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id"), nullable=True) + customer_name: Mapped[str] = mapped_column(String(128), default="") + phone: Mapped[str] = mapped_column(String(32), default="") + address: Mapped[str] = mapped_column(String(255), default="") + measure_date: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True) + surveyor: Mapped[str] = mapped_column(String(64), default="") # 量尺人 + # 0待量尺 1已量尺 2已出图 + status: Mapped[int] = mapped_column(default=0) + # 量尺数据 JSON(洞口尺寸清单) + data: Mapped[str] = mapped_column(Text, default="[]") + note: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) diff --git a/src/huamenchuang/models/order.py b/src/huamenchuang/models/order.py index c386c42..47c069f 100644 --- a/src/huamenchuang/models/order.py +++ b/src/huamenchuang/models/order.py @@ -36,6 +36,8 @@ class Order(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) order_no: Mapped[str] = mapped_column(String(32), unique=True) # DO+YYYYMMDD+4位流水 + # 订单类型: 0=门窗订单 1=阳光房订单(对齐目标站 sunny_house_order) + order_type: Mapped[int] = mapped_column(Integer, default=0, index=True) contract_id: Mapped[int | None] = mapped_column( ForeignKey("contracts.id"), nullable=True, index=True ) diff --git a/src/huamenchuang/schemas/order.py b/src/huamenchuang/schemas/order.py index 59e9b74..b73481b 100644 --- a/src/huamenchuang/schemas/order.py +++ b/src/huamenchuang/schemas/order.py @@ -11,6 +11,7 @@ class OrderCreate(BaseModel): """创建订单(对齐目标站契约:contract_id + drawing_data + pic_url)。""" contract_id: int | None = None + order_type: int = Field(default=0, ge=0, le=1, description="0门窗 1阳光房") drawing_data: str = Field(default="", description="shapeManager 序列化 JSON 字符串") pic_url: str = "" customer_id: int | None = None diff --git a/src/huamenchuang/services/order_service.py b/src/huamenchuang/services/order_service.py index fc8ae02..47f5b40 100644 --- a/src/huamenchuang/services/order_service.py +++ b/src/huamenchuang/services/order_service.py @@ -27,6 +27,7 @@ class OrderService: customer_name, phone, address = contract.customer_name, contract.phone, contract.address order = Order( order_no=await self.repo.next_order_no(user.company_id), + order_type=payload.order_type, contract_id=payload.contract_id, drawing_data=payload.drawing_data, pic_url=payload.pic_url,